From e7d153ce6b3ec47668a1483e32b9aaea9accc709 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: Mon, 12 Jan 2026 00:03:24 +0300 Subject: [PATCH 01/21] Try add perforation --- src/components/ConfigStep.tsx | 288 +++++++++++++------------ src/services/geometryGenerator.ts | 336 ++++++++++++++++++++++-------- src/types.ts | 16 +- 3 files changed, 412 insertions(+), 228 deletions(-) diff --git a/src/components/ConfigStep.tsx b/src/components/ConfigStep.tsx index 37c856e..ca2950d 100644 --- a/src/components/ConfigStep.tsx +++ b/src/components/ConfigStep.tsx @@ -1,153 +1,171 @@ -import React from 'react'; -import { AppConfig } from '../types'; -import { Ruler, Box, Layers, Minimize2, CircleDashed } from 'lucide-react'; +import React, { useEffect, useRef } from 'react'; +import { AppConfig, PerforationPattern } from '../types'; +import { Settings2, Grid, Circle, Triangle, Hexagon, LayoutGrid } from 'lucide-react'; interface Props { config: AppConfig; - onChange: (newConfig: AppConfig) => void; + onChange: (config: AppConfig) => void; } -const InputGroup: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( -
- -
{children}
-
-); - -const NumberInput = ({ - label, - value, - onChange, - max -}: { - label: string; - value: number; - onChange: (val: number) => void; - max?: number -}) => ( -
- {label} - onChange(parseFloat(e.target.value) || 0)} - className="w-full bg-slate-800 border border-slate-700 rounded p-2 pl-8 text-white focus:ring-2 focus:ring-primary outline-none" - /> - мм -
-); - export const ConfigStep: React.FC = ({ config, onChange }) => { - const updateDrawer = (key: keyof AppConfig['drawer'], val: number) => { - onChange({ ...config, drawer: { ...config.drawer, [key]: val } }); + + // Инициализация дефолтных значений, если их нет + useEffect(() => { + if (!config.perforation) { + onChange({ + ...config, + perforation: { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 4 } + }); + } + }, []); + + const perf = config.perforation || { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 4 }; + + const updatePerf = (updates: Partial) => { + onChange({ ...config, perforation: { ...perf, ...updates } }); + }; + + const updateDrawer = (key: keyof typeof config.drawer, value: number) => { + onChange({ ...config, drawer: { ...config.drawer, [key]: value } }); + }; + + // --- RENDER PREVIEW (SVG) --- + const renderPreview = () => { + if (!perf.enabled) return
Перфорация выключена
; + + const size = perf.diameter; + const gap = Math.max(2, perf.spacing); // Минимальный зазор 2мм + const step = size + gap; + const W = 140; + const H = 100; + + const elements = []; + const rows = Math.floor(H / (step * 0.866)); // 0.866 для сот (sin 60) + const cols = Math.floor(W / step); + + for(let j=0; j W - 10 || y > H - 10) continue; + + if (perf.pattern === 'circle') { + elements.push(); + } else if (perf.pattern === 'hexagon') { + // Рисуем шестиугольник + const r = size / 2; + const points = []; + for (let k = 0; k < 6; k++) { + const angle = (k * 60 + 30) * Math.PI / 180; + points.push(`${x + r * Math.cos(angle)},${y + r * Math.sin(angle)}`); + } + elements.push(); + } else if (perf.pattern === 'triangle') { + const r = size / 2; + const angleOffset = isOdd ? 180 : 0; + const points = []; + for (let k = 0; k < 3; k++) { + const angle = (k * 120 - 90 + angleOffset) * Math.PI / 180; + points.push(`${x + r * Math.cos(angle)},${y + r * Math.sin(angle)}`); + } + elements.push(); + } + } + } + + return ( + + {elements} + + ); }; return ( -
-

- 1. Размеры -

+
+ {/* 1. ГАБАРИТЫ */} +
+

1. Размеры ящика

+
+
+ + updateDrawer('width', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary outline-none mt-1"/> +
+
+ + updateDrawer('depth', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary outline-none mt-1"/> +
+
+ + updateDrawer('height', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary outline-none mt-1"/> +
+
+
-
- {/* Drawer Dimensions */} -
-

- Внутренние размеры ящика -

- - updateDrawer('width', v)} /> - - - updateDrawer('depth', v)} /> - - - updateDrawer('height', v)} /> - + {/* 2. ПЕРФОРАЦИЯ */} +
+
+

2. Перфорация (узоры)

+
+ {perf.enabled ? 'Включено' : 'Выключено'} + +
- {/* Settings */} -
-

- Параметры печати -

- - {/* Wall Thickness */} -
-
- - - {config.wallThickness.toFixed(1)} мм - -
-
- 0.4 - onChange({...config, wallThickness: parseFloat(e.target.value)})} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-primary hover:accent-blue-400 transition-all" - /> - 3.2 -
-
+
+ {/* Controls */} +
+
+ +
+ + + +
+
- {/* Corner Radius (NEW) */} -
-
- - - {config.cornerRadius?.toFixed(0) || 0} мм - -
-
- 0 - 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" - /> - 20 -
-
- - {/* Printer Tolerance */} -
-
- - - {config.printerTolerance.toFixed(1)} мм - -
-
- 0.0 - onChange({...config, printerTolerance: parseFloat(e.target.value)})} - className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-accent hover:accent-amber-400 transition-all" - /> - 2.0 -
-
+
+
+ + updatePerf({ diameter: Math.min(12, parseFloat(e.target.value)) })} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white mt-1"/> +
+
+ + updatePerf({ spacing: Math.max(2, parseFloat(e.target.value)) })} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white mt-1"/> +
+
+
+ {/* Preview */} +
+ Предпросмотр + {renderPreview()} +
-
+
+ + {/* 3. ПРОЧЕЕ */} +
+

Дополнительно

+
+
+ + onChange({...config, wallThickness: parseFloat(e.target.value)})} className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm w-20 text-gray-300"/> +
+
+ + onChange({...config, cornerRadius: parseFloat(e.target.value)})} className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm w-20 text-gray-300"/> +
+
+
); }; \ No newline at end of file diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 8633c7d..747c4df 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,33 +2,54 @@ 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 = {}; + partitions.forEach(p => { limits[p.id] = { min: 0, max: 1 }; }); + for (let pass = 0; pass < 4; pass++) { + partitions.forEach(target => { + let newMin = 0; + let newMax = 1; + const center = target.offset; + partitions.forEach(obstacle => { + if (target.id === obstacle.id || target.axis === obstacle.axis) return; + const obsMin = limits[obstacle.id].min; + const obsMax = limits[obstacle.id].max; + const EPS = 0.002; + if (target.offset >= obsMin - EPS && target.offset <= obsMax + EPS) { + 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 : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; const safeParts = splits?.partitions || {}; - const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; - let partCounter = 1; - for (let i = 0; i < xPoints.length - 1; i++) { 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 < 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; const realDepth = rawD - config.printerTolerance; const realX = rawX + (config.printerTolerance / 2); const realY = rawY + (config.printerTolerance / 2); - parts.push({ id: `part-${partCounter}`, name: `Ячейка ${i+1}-${j+1}`, @@ -46,118 +67,250 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ --- +// --- ГЕОМЕТРИЯ С ОТВЕРСТИЯМИ --- +// Функция добавляет отверстия в THREE.Shape +const applyPerforationToShape = (shape: THREE.Shape, width: number, height: number, config: AppConfig) => { + if (!config.perforation?.enabled) return; + + const { pattern, diameter, spacing } = config.perforation; + const step = diameter + Math.max(2, spacing); // Шаг сетки + + // Отступы от краев (чтобы не портить структуру) + const marginX = 4; + const marginY = 4; + + // Генерируем сетку отверстий + const cols = Math.floor((width - marginX * 2) / step); + const rows = Math.floor((height - marginY * 2) / (pattern === 'circle' ? step : step * 0.866)); + + // Центрируем паттерн + const startX = (width - ((cols - 1) * step)) / 2; + const startY = (height - ((rows - 1) * (pattern === 'circle' ? step : step * 0.866))) / 2; + + for (let j = 0; j < rows; j++) { + const isOdd = j % 2 !== 0; + const y = startY + j * (pattern === 'circle' ? step : step * 0.866); + + for (let i = 0; i < cols; i++) { + let x = startX + i * step; + if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) x += step / 2; + + // Доп. проверка границ + if (x < marginX + diameter/2 || x > width - marginX - diameter/2) continue; + if (y < marginY + diameter/2 || y > height - marginY - diameter/2) continue; + + const hole = new THREE.Path(); + const r = diameter / 2; + + if (pattern === 'circle') { + hole.absarc(x, y, r, 0, Math.PI * 2, true); + } else if (pattern === 'hexagon') { + for (let k = 0; k < 6; k++) { + const angle = (k * 60 + 30) * Math.PI / 180; + const px = x + r * Math.cos(angle); + const py = y + r * Math.sin(angle); + if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); + } + hole.closePath(); + } else if (pattern === 'triangle') { + // Чередуем треугольники (вверх/вниз) + const rot = isOdd ? 180 : 0; + for (let k = 0; k < 3; k++) { + const angle = (k * 120 - 90 + rot) * Math.PI / 180; + const px = x + r * Math.cos(angle); + const py = y + r * Math.sin(angle); + if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); + } + hole.closePath(); + } + shape.holes.push(hole); + } + } +}; + +const createRectWithHoles = (w: number, h: number, config: AppConfig): THREE.Shape => { + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + shape.lineTo(w, 0); + shape.lineTo(w, h); + shape.lineTo(0, h); + shape.lineTo(0, 0); + applyPerforationToShape(shape, w, h, config); + return shape; +}; + +// Стандартный прямоугольник для пола (без дырок) 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 - 0.1, height / 2 - 0.1); - if (r <= 0.1) { - shape.moveTo(x, y); - shape.lineTo(x + width, y); - shape.lineTo(x + width, y + height); - shape.lineTo(x, y + height); - shape.lineTo(x, y); + 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); + 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; }; const createConcaveFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(radius, 0); - shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); - shape.lineTo(0, 0); - return shape; + shape.moveTo(0, 0); shape.lineTo(radius, 0); shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); shape.lineTo(0, 0); return shape; }; +// --- ГЛАВНАЯ ФУНКЦИЯ ГЕНЕРАЦИИ --- export const createBinGeometry = ( - width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [] + width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig // Config нужен для дырок ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; + + // Безопасный конфиг если не передан + const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - // ДНО И ВНЕШНИЕ СТЕНКИ + // 1. ДНО (Floor) - Без дырок const floorShape = createRoundedRectShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); floorGeo.rotateX(-Math.PI / 2); geometries.push(floorGeo); - const outerShape = createRoundedRectShape(width, depth, radius); - const innerRadius = Math.max(0.1, radius - thickness); - const innerWidth = width - (2 * thickness); - const innerDepth = depth - (2 * thickness); + // 2. ВНЕШНИЕ СТЕНКИ (С ДЫРКАМИ) + // Мы строим их как 4 отдельные панели, чтобы можно было применить 2D паттерн дырок + const innerW = width - 2 * thickness; + const innerD = depth - 2 * thickness; + const wallH = height - thickness; + + // Front & Back Walls (Width x Height) + const wallShapeFB = createRectWithHoles(innerW, wallH, safeConfig); + // Left & Right Walls (Depth x Height) - используем полную глубину минус отступы, чтобы углы сошлись + // Но для простоты: Front/Back стоят "между" Left/Right. + // Left/Right имеют длину = depth. Front/Back длину = width - 2*thick. + const wallShapeLR = createRectWithHoles(depth, wallH, safeConfig); + + // Функция для создания стенки, её экструзии и поворота + const addWall = (shape: THREE.Shape, len: number, pos: [number, number, number], rotY: number) => { + // Shape рисуется от 0,0. Экструдим на толщину. + const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); + // Центрируем shape по X (чтобы вращать удобно) + geo.translate(-len/2, 0, 0); + + // Поворачиваем: Сначала "поднимаем" shape вертикально? + // По умолчанию shape в XY плоскости. Extrude идет в Z. + // Нам нужно чтобы стена стояла. + // XY plane -> Wall upright. + + // Сдвигаем на позицию (x, y=thickness, z) + // Y в ThreeJS это "вверх". Стенки начинаются с floor thickness. + + // Корректировка пивота + geo.translate(len/2, 0, 0); // Вернули в 0..len по X + geo.translate(-len/2, 0, 0); // Центрируем: -len/2 .. len/2 + + // Вращение + geo.rotateY(rotY); + // Позиция + geo.translate(pos[0], thickness, pos[2]); // Y = thickness (на полу) + geometries.push(geo); + }; + + // ! ВАЖНО: Текущая реализация createRectWithHoles рисует прямоугольник 0..W, 0..H в плоскости XY. + // Extrude добавляет глубину Z. + // Стенка "стоит" если Z - это толщина. - if (innerWidth > 0.1 && innerDepth > 0.1) { - const innerHole = createRoundedRectShape(innerWidth, innerDepth, innerRadius); - outerShape.holes.push(innerHole); - } + // Front Wall (Z = innerD/2 + thick/2 = depth/2 - thick/2) -> Позиция Z чуть смещена + // Back Wall (Z = -depth/2 + thick/2) + + // Front (Z+) + const geoF = new THREE.ExtrudeGeometry(wallShapeFB, { depth: thickness, bevelEnabled: false }); + geoF.translate(-innerW/2, 0, 0); // Центрируем по X + geoF.translate(0, thickness, innerD/2); // Поднимаем на пол, сдвигаем вперед + geometries.push(geoF); - const wallHeight = height - thickness; - const wallGeo = new THREE.ExtrudeGeometry(outerShape, { depth: wallHeight, bevelEnabled: false }); - wallGeo.rotateX(-Math.PI / 2); - wallGeo.translate(0, thickness, 0); - geometries.push(wallGeo); + // Back (Z-) + const geoB = new THREE.ExtrudeGeometry(wallShapeFB, { depth: thickness, bevelEnabled: false }); + geoB.translate(-innerW/2, 0, -thickness); // Центрируем, толщина назад + geoB.translate(0, thickness, -innerD/2); + geometries.push(geoB); + + // Left (X-) + const geoL = new THREE.ExtrudeGeometry(wallShapeLR, { depth: thickness, bevelEnabled: false }); + geoL.rotateY(Math.PI / 2); // Поворачиваем на 90 + geoL.translate(-width/2, thickness, -depth/2); // Ставим слева + geometries.push(geoL); + + // Right (X+) + const geoR = new THREE.ExtrudeGeometry(wallShapeLR, { depth: thickness, bevelEnabled: false }); + geoR.rotateY(Math.PI / 2); + geoR.translate(width/2 - thickness, thickness, -depth/2); + geometries.push(geoR); + + + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (С ДЫРКАМИ) + const limitMap = solveWallLimits(partitions); - // ВНУТРЕННИЕ ПЕРЕГОРОДКИ (СТРОГО ПО ДАННЫМ, БЕЗ SOLVER) partitions.forEach(p => { - // Берем данные напрямую. Если в 2D нарисовано от 0.2 до 0.8, тут будет 0.2 до 0.8. - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; - + const { min: pMin, max: pMax } = limitMap[p.id]; if (pMax - pMin < 0.01) return; const lengthRatio = pMax - pMin; - const midRatio = pMin + (lengthRatio / 2); - - let pWidth = 0, pDepth = 0, pX = 0, pY = 0; + // Реальная длина стенки в мм + let wallLen = 0; + let pX = 0, pZ = 0; // Центр стенки + let rotY = 0; + if (p.axis === 'x') { - pWidth = thickness; - pDepth = lengthRatio * innerDepth; - pX = (-innerWidth / 2) + (innerWidth * p.offset); - pY = (-innerDepth / 2) + (innerDepth * midRatio); + // Вертикальная на экране = Вдоль Z в 3D + wallLen = lengthRatio * innerD; + // Позиция центра + pX = (-innerW / 2) + (innerW * p.offset); + // Начало по Z + const startZ = (-innerD / 2) + (innerD * pMin); + pZ = startZ; + rotY = Math.PI / 2; } else { - pWidth = lengthRatio * innerWidth; - pDepth = thickness; - pX = (-innerWidth / 2) + (innerWidth * midRatio); - pY = (-innerDepth / 2) + (innerDepth * p.offset); + // Горизонтальная на экране = Вдоль X в 3D + wallLen = lengthRatio * innerW; + const startX = (-innerW / 2) + (innerW * pMin); + pX = startX; + pZ = (-innerD / 2) + (innerD * p.offset); + rotY = 0; } - 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); + // Создаем профиль с дырками + const partShape = createRectWithHoles(wallLen, wallH, safeConfig); + const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); + + // Поворачиваем и ставим на место + // Изначально shape в XY (0..len, 0..height) + if (p.axis === 'x') { + // Нужно повернуть Y 90. + partGeo.rotateY(Math.PI / 2); + // После поворота: X -> Z, Y -> Y, Z -> X + // Начало было 0,0,0. Стало 0,0,0. Длина ушла в +Z. + partGeo.translate(pX - thickness/2, thickness, pZ); + } else { + // Вдоль X. Ничего вращать не надо, кроме смещения на толщину + partGeo.translate(pX, thickness, pZ - thickness/2); + } + geometries.push(partGeo); - // СКРУГЛЕНИЯ (Fillets) + // --- FILLETS (Остаются как были, они вертикальные, дырки их не касаются) --- if (p.rounded && radius > 1) { + // Логика галтелей остается прежней (она работает хорошо) const filletR = Math.min(radius, 5); const filletShape = createConcaveFilletShape(filletR); - - // Функция проверки высоты соседа (простая проверка на пересечение) + 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 => { - if (n.axis === p.axis) return false; // Перпендикуляр - const nMin = n.min ?? 0; - const nMax = n.max ?? 1; - // Совпадает ли позиция? - if (Math.abs(n.offset - pos) > 0.002) return false; - // Перекрывает ли? - return p.offset > nMin && p.offset < nMax; + if (n.axis === p.axis) return false; + const nLims = limitMap[n.id]; + return Math.abs(n.offset - pos) < 0.002 && p.offset >= nLims.min && p.offset <= nLims.max; }); return neighbor ? neighbor.height : 0; }; @@ -165,33 +318,36 @@ export const createBinGeometry = ( const hStart = Math.min(p.height, getNeighborHeight(pMin)); const hEnd = Math.min(p.height, getNeighborHeight(pMax)); - const addFillet = (x: number, y: number, rotY: number, h: number) => { + const addFillet = (x: number, y: number, rot: number, h: number) => { if (h <= 1) return; const geo = new THREE.ExtrudeGeometry(filletShape, { depth: h, bevelEnabled: false }); geo.rotateX(-Math.PI / 2); - geo.rotateY(rotY); + geo.rotateY(rot); geo.translate(x, thickness, y); geometries.push(geo); }; const t = thickness / 2; - + // Координаты для галтелей if (p.axis === 'x') { - const topY = (-innerDepth / 2) + (innerDepth * pMin); - const botY = (-innerDepth / 2) + (innerDepth * pMax); + // ... тот же код галтелей + const topZ = (-innerD / 2) + (innerD * pMin); + const botZ = (-innerD / 2) + (innerD * pMax); + const centerX = (-innerW/2) + (innerW * p.offset); - addFillet(pX - t, topY, Math.PI, hStart); - addFillet(pX + t, topY, -Math.PI / 2, hStart); - addFillet(pX - t, botY, Math.PI / 2, hEnd); - addFillet(pX + t, botY, 0, hEnd); + addFillet(centerX - t, topZ, Math.PI, hStart); + addFillet(centerX + t, topZ, -Math.PI / 2, hStart); + addFillet(centerX - t, botZ, Math.PI / 2, hEnd); + addFillet(centerX + t, botZ, 0, hEnd); } else { - const leftX = (-innerWidth / 2) + (innerWidth * pMin); - const rightX = (-innerWidth / 2) + (innerWidth * pMax); + const leftX = (-innerW / 2) + (innerW * pMin); + const rightX = (-innerW / 2) + (innerW * pMax); + const centerZ = (-innerD/2) + (innerD * p.offset); - addFillet(leftX, pY - t, 0, hStart); - addFillet(leftX, pY + t, -Math.PI / 2, hStart); - addFillet(rightX, pY - t, Math.PI / 2, hEnd); - addFillet(rightX, pY + t, Math.PI, hEnd); + addFillet(leftX, centerZ - t, 0, hStart); + addFillet(leftX, centerZ + t, -Math.PI / 2, hStart); + addFillet(rightX, centerZ - t, Math.PI / 2, hEnd); + addFillet(rightX, centerZ + t, Math.PI, hEnd); } } }); diff --git a/src/types.ts b/src/types.ts index c2a7c17..78641f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,19 +4,29 @@ export interface DrawerDimensions { height: number; } +export type PerforationPattern = 'circle' | 'hexagon' | 'triangle'; + +export interface PerforationConfig { + enabled: boolean; + pattern: PerforationPattern; + diameter: number; // Размер отверстия + spacing: number; // Расстояние между центрами (шаг) +} + export interface AppConfig { drawer: DrawerDimensions; wallThickness: number; printerTolerance: number; cornerRadius: number; + perforation: PerforationConfig; // Новая секция } export interface Partition { id: string; axis: 'x' | 'y'; - offset: number; // Позиция (0.0 - 1.0) - min: number; // Начало стенки (0.0 - 1.0) - max: number; // Конец стенки (0.0 - 1.0) + offset: number; + min: number; + max: number; height: number; rounded: boolean; } From 58a2e0468fb951e3c7296c84316939be3b737a2a 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: Mon, 12 Jan 2026 00:13:32 +0300 Subject: [PATCH 02/21] Fix config view --- src/components/ConfigStep.tsx | 263 ++++++++++++++++++++++++---------- 1 file changed, 188 insertions(+), 75 deletions(-) diff --git a/src/components/ConfigStep.tsx b/src/components/ConfigStep.tsx index ca2950d..b71989e 100644 --- a/src/components/ConfigStep.tsx +++ b/src/components/ConfigStep.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect } from 'react'; import { AppConfig, PerforationPattern } from '../types'; -import { Settings2, Grid, Circle, Triangle, Hexagon, LayoutGrid } from 'lucide-react'; +import { Settings2, Box, Ruler, LayoutGrid, Circle, Hexagon, Triangle, Scan } from 'lucide-react'; interface Props { config: AppConfig; @@ -9,17 +9,17 @@ interface Props { export const ConfigStep: React.FC = ({ config, onChange }) => { - // Инициализация дефолтных значений, если их нет + // Инициализация дефолтных значений перфорации useEffect(() => { if (!config.perforation) { onChange({ ...config, - perforation: { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 4 } + perforation: { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 2 } }); } }, []); - const perf = config.perforation || { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 4 }; + const perf = config.perforation || { enabled: false, pattern: 'hexagon', diameter: 8, spacing: 2 }; const updatePerf = (updates: Partial) => { onChange({ ...config, perforation: { ...perf, ...updates } }); @@ -29,41 +29,46 @@ export const ConfigStep: React.FC = ({ config, onChange }) => { onChange({ ...config, drawer: { ...config.drawer, [key]: value } }); }; - // --- RENDER PREVIEW (SVG) --- + // --- RENDER PREVIEW --- const renderPreview = () => { if (!perf.enabled) return
Перфорация выключена
; const size = perf.diameter; - const gap = Math.max(2, perf.spacing); // Минимальный зазор 2мм + const gap = Math.max(2, perf.spacing); const step = size + gap; - const W = 140; - const H = 100; + const W = 200; + const H = 120; const elements = []; - const rows = Math.floor(H / (step * 0.866)); // 0.866 для сот (sin 60) + // Приблизительный расчет для превью + const rows = Math.floor(H / (step * 0.866)); const cols = Math.floor(W / step); + const startX = (W - (cols * step)) / 2; + const startY = (H - (rows * step * 0.866)) / 2; + for(let j=0; j W - 10 || y > H - 10) continue; + if (x > W - size || y > H - size) continue; + + const color = "#3b82f6"; if (perf.pattern === 'circle') { - elements.push(); + elements.push(); } else if (perf.pattern === 'hexagon') { - // Рисуем шестиугольник const r = size / 2; const points = []; for (let k = 0; k < 6; k++) { const angle = (k * 60 + 30) * Math.PI / 180; points.push(`${x + r * Math.cos(angle)},${y + r * Math.sin(angle)}`); } - elements.push(); + elements.push(); } else if (perf.pattern === 'triangle') { const r = size / 2; const angleOffset = isOdd ? 180 : 0; @@ -72,100 +77,208 @@ export const ConfigStep: React.FC = ({ config, onChange }) => { const angle = (k * 120 - 90 + angleOffset) * Math.PI / 180; points.push(`${x + r * Math.cos(angle)},${y + r * Math.sin(angle)}`); } - elements.push(); + elements.push(); } } } return ( - + {elements} ); }; return ( -
- {/* 1. ГАБАРИТЫ */} -
-

1. Размеры ящика

-
-
- - updateDrawer('width', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary outline-none mt-1"/> -
-
- - updateDrawer('depth', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary outline-none mt-1"/> -
-
- - updateDrawer('height', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-primary outline-none mt-1"/> -
-
-
+
+ + {/* ВЕРХНИЙ БЛОК: Размеры и Параметры печати */} +
+ + {/* 1. РАЗМЕРЫ */} +
+

+ 1. Размеры +

+ +
+
+

Внутренние размеры ящика

+
+
+ +
+ updateDrawer('width', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-4 pr-12 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 outline-none transition-all font-mono"/> + MM +
+
+
+ +
+ updateDrawer('depth', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-4 pr-12 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 outline-none transition-all font-mono"/> + MM +
+
+
+ +
+ updateDrawer('height', parseFloat(e.target.value))} className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-4 pr-12 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 outline-none transition-all font-mono"/> + MM +
+
+
+
+
+
- {/* 2. ПЕРФОРАЦИЯ */} -
-
-

2. Перфорация (узоры)

-
- {perf.enabled ? 'Включено' : 'Выключено'} + {/* 2. ПАРАМЕТРЫ ПЕЧАТИ */} +
+

+ Параметры печати +

+ +
+ + {/* Wall Thickness */} +
+
+ Толщина стенок + {config.wallThickness} мм +
+ onChange({...config, wallThickness: parseFloat(e.target.value)})} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ 0.84.0 +
+
+ + {/* Corner Radius */} +
+
+ Радиус скругления + {config.cornerRadius} мм +
+ onChange({...config, cornerRadius: parseFloat(e.target.value)})} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-purple-500" + /> +
+ 020 +
+
+ + {/* Tolerance */} +
+
+ Зазор (Tolerance) + {config.printerTolerance} мм +
+ onChange({...config, printerTolerance: parseFloat(e.target.value)})} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-yellow-500" + /> +
+ 0.01.0 +
+
+ +
+
+
+ + {/* НИЖНИЙ БЛОК: ПЕРФОРАЦИЯ */} +
+
+

+ 2. Перфорация (узоры) +

+
+ {perf.enabled ? 'Включено' : 'Выключено'}
-
- {/* Controls */} -
+
+ {/* Настройки */} +
+ + {/* Тип узора */}
- -
- - - + +
+ + +
-
+ {/* Ползунки параметров */} +
+ {/* Diameter */}
- - updatePerf({ diameter: Math.min(12, parseFloat(e.target.value)) })} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white mt-1"/> +
+ Диаметр отверстий + {perf.diameter} мм +
+ updatePerf({ diameter: parseFloat(e.target.value) })} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ 2 мм12 мм +
+ + {/* Spacing */}
- - updatePerf({ spacing: Math.max(2, parseFloat(e.target.value)) })} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white mt-1"/> +
+ Зазор (между отверстиями) + {perf.spacing} мм +
+ updatePerf({ spacing: parseFloat(e.target.value) })} + className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ 2 мм10 мм +
{/* Preview */} -
- Предпросмотр - {renderPreview()} +
+
+ + Масштаб условен +
+
+
+ {renderPreview()} +
+
- {/* 3. ПРОЧЕЕ */} -
-

Дополнительно

-
-
- - onChange({...config, wallThickness: parseFloat(e.target.value)})} className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm w-20 text-gray-300"/> -
-
- - onChange({...config, cornerRadius: parseFloat(e.target.value)})} className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm w-20 text-gray-300"/> -
-
-
); }; \ No newline at end of file From 9e4c88e424c2024845d4779eaf329ca11cbaa6ac 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: Mon, 12 Jan 2026 00:36:02 +0300 Subject: [PATCH 03/21] 1 --- src/services/geometryGenerator.ts | 424 +++++++++++++++--------------- 1 file changed, 209 insertions(+), 215 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 747c4df..c1b6d58 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,59 +2,44 @@ 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 = {}; - partitions.forEach(p => { limits[p.id] = { min: 0, max: 1 }; }); - for (let pass = 0; pass < 4; pass++) { - partitions.forEach(target => { - let newMin = 0; - let newMax = 1; - const center = target.offset; - partitions.forEach(obstacle => { - if (target.id === obstacle.id || target.axis === obstacle.axis) return; - const obsMin = limits[obstacle.id].min; - const obsMax = limits[obstacle.id].max; - const EPS = 0.002; - if (target.offset >= obsMin - EPS && target.offset <= obsMax + EPS) { - 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 : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; const safeParts = splits?.partitions || {}; + const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; + let partCounter = 1; + for (let i = 0; i < xPoints.length - 1; i++) { 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 < 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; - const realDepth = rawD - config.printerTolerance; - const realX = rawX + (config.printerTolerance / 2); - const realY = rawY + (config.printerTolerance / 2); + + // Внутренний отступ для визуализации "объема" (цветных кубиков) + // Чтобы они не сливались со стенками + const gap = config.wallThickness + 0.5; + + const realWidth = rawW - gap * 2; + const realDepth = rawD - gap * 2; + const realX = rawX + gap; + const realY = rawY + gap; + parts.push({ id: `part-${partCounter}`, name: `Ячейка ${i+1}-${j+1}`, - width: realWidth, - depth: realDepth, + width: Math.max(1, realWidth), + depth: Math.max(1, realDepth), height: config.drawer.height, x: realX, y: realY, @@ -67,54 +52,67 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ С ОТВЕРСТИЯМИ --- +// --- ГЕНЕРАЦИЯ ФОРМ С ОТВЕРСТИЯМИ --- -// Функция добавляет отверстия в THREE.Shape -const applyPerforationToShape = (shape: THREE.Shape, width: number, height: number, config: AppConfig) => { - if (!config.perforation?.enabled) return; +// Создает форму прямоугольника с отверстиями по паттерну +const createPerforatedShape = (width: number, height: number, config: AppConfig): THREE.Shape => { + const shape = new THREE.Shape(); + // Рисуем внешний контур (CCW) + shape.moveTo(0, 0); + shape.lineTo(width, 0); + shape.lineTo(width, height); + shape.lineTo(0, height); + shape.lineTo(0, 0); + + // Если перфорация выключена или стенка слишком маленькая, возвращаем сплошной + if (!config.perforation?.enabled || width < 15 || height < 15) return shape; const { pattern, diameter, spacing } = config.perforation; - const step = diameter + Math.max(2, spacing); // Шаг сетки + const step = diameter + Math.max(2, spacing); - // Отступы от краев (чтобы не портить структуру) - const marginX = 4; - const marginY = 4; + // Отступы от краев (чтобы не портить прочность) + const margin = 6; - // Генерируем сетку отверстий - const cols = Math.floor((width - marginX * 2) / step); - const rows = Math.floor((height - marginY * 2) / (pattern === 'circle' ? step : step * 0.866)); + // Эффективная область для дырок + const effW = width - margin * 2; + const effH = height - margin * 2; - // Центрируем паттерн - const startX = (width - ((cols - 1) * step)) / 2; - const startY = (height - ((rows - 1) * (pattern === 'circle' ? step : step * 0.866))) / 2; + if (effW <= 0 || effH <= 0) return shape; + + // Расчет сетки + const rowHeight = pattern === 'circle' ? step : step * 0.866; + const cols = Math.floor(effW / step); + const rows = Math.floor(effH / rowHeight); + + // Центрирование + const startX = margin + (effW - (cols - 1) * step) / 2; + const startY = margin + (effH - (rows - 1) * rowHeight) / 2; for (let j = 0; j < rows; j++) { const isOdd = j % 2 !== 0; - const y = startY + j * (pattern === 'circle' ? step : step * 0.866); + const y = startY + j * rowHeight; for (let i = 0; i < cols; i++) { let x = startX + i * step; if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) x += step / 2; - // Доп. проверка границ - if (x < marginX + diameter/2 || x > width - marginX - diameter/2) continue; - if (y < marginY + diameter/2 || y > height - marginY - diameter/2) continue; + // Проверка, что отверстие внутри (с запасом на радиус) + const r = diameter / 2; + if (x - r < margin || x + r > width - margin || y - r < margin || y + r > height - margin) continue; const hole = new THREE.Path(); - const r = diameter / 2; - + if (pattern === 'circle') { - hole.absarc(x, y, r, 0, Math.PI * 2, true); + hole.absarc(x, y, r, 0, Math.PI * 2, true); // CW для отверстий } else if (pattern === 'hexagon') { for (let k = 0; k < 6; k++) { - const angle = (k * 60 + 30) * Math.PI / 180; + const angle = (k * 60 + 30) * Math.PI / 180; // 30 deg offset for flat top const px = x + r * Math.cos(angle); const py = y + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } hole.closePath(); } else if (pattern === 'triangle') { - // Чередуем треугольники (вверх/вниз) const rot = isOdd ? 180 : 0; for (let k = 0; k < 3; k++) { const angle = (k * 120 - 90 + rot) * Math.PI / 180; @@ -127,236 +125,232 @@ const applyPerforationToShape = (shape: THREE.Shape, width: number, height: numb shape.holes.push(hole); } } -}; -const createRectWithHoles = (w: number, h: number, config: AppConfig): THREE.Shape => { - const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(w, 0); - shape.lineTo(w, h); - shape.lineTo(0, h); - shape.lineTo(0, 0); - applyPerforationToShape(shape, w, h, config); return shape; }; -// Стандартный прямоугольник для пола (без дырок) -const createRoundedRectShape = (width: number, height: number, radius: number): THREE.Shape => { +// Форма пола со скругленными углами +const createFloorShape = (width: number, depth: 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 - 0.1, height / 2 - 0.1); + const y = -depth / 2; + const r = Math.min(radius, width / 2, depth / 2); + if (r <= 0.1) { - shape.moveTo(x, y); shape.lineTo(x + width, y); shape.lineTo(x + width, y + height); shape.lineTo(x, y + height); shape.lineTo(x, y); + shape.moveTo(x, y); + shape.lineTo(x + width, y); + shape.lineTo(x + width, y + depth); + shape.lineTo(x, y + depth); + 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); + shape.moveTo(x, y + r); + shape.lineTo(x, y + depth - r); + shape.quadraticCurveTo(x, y + depth, x + r, y + depth); + shape.lineTo(x + width - r, y + depth); + shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; }; -const createConcaveFilletShape = (radius: number): THREE.Shape => { +// Галтель (вогнутый уголок) +const createFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); - shape.moveTo(0, 0); shape.lineTo(radius, 0); shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); shape.lineTo(0, 0); return shape; + shape.moveTo(0, 0); + shape.lineTo(radius, 0); + shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); + shape.lineTo(0, 0); + return shape; }; -// --- ГЛАВНАЯ ФУНКЦИЯ ГЕНЕРАЦИИ --- +// --- ГЛАВНАЯ ФУНКЦИЯ --- + export const createBinGeometry = ( - width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig // Config нужен для дырок + width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig ): THREE.BufferGeometry => { const geometries: THREE.BufferGeometry[] = []; - - // Безопасный конфиг если не передан const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - // 1. ДНО (Floor) - Без дырок - const floorShape = createRoundedRectShape(width, depth, radius); + // 1. ПОЛ (Сплошной) + const floorShape = createFloorShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); + floorGeo.rotateX(-Math.PI / 2); // Кладем на плоскость XZ geometries.push(floorGeo); - // 2. ВНЕШНИЕ СТЕНКИ (С ДЫРКАМИ) - // Мы строим их как 4 отдельные панели, чтобы можно было применить 2D паттерн дырок - const innerW = width - 2 * thickness; - const innerD = depth - 2 * thickness; + // 2. ВНЕШНИЕ СТЕНКИ + // Строим их "лежа" в плоскости XY, а потом поворачиваем и ставим на место. + // Это позволяет использовать 2D логику для отверстий. + const wallH = height - thickness; + const sideWallW = depth - (2 * thickness); // Боковые стенки встанут МЕЖДУ передней и задней - // Front & Back Walls (Width x Height) - const wallShapeFB = createRectWithHoles(innerW, wallH, safeConfig); - // Left & Right Walls (Depth x Height) - используем полную глубину минус отступы, чтобы углы сошлись - // Но для простоты: Front/Back стоят "между" Left/Right. - // Left/Right имеют длину = depth. Front/Back длину = width - 2*thick. - const wallShapeLR = createRectWithHoles(depth, wallH, safeConfig); + // Передняя и Задняя (Полная ширина) + const shapeFB = createPerforatedShape(width, wallH, safeConfig); + // Левая и Правая (Укороченные, чтобы встать в паз) + const shapeLR = createPerforatedShape(sideWallW, wallH, safeConfig); - // Функция для создания стенки, её экструзии и поворота - const addWall = (shape: THREE.Shape, len: number, pos: [number, number, number], rotY: number) => { - // Shape рисуется от 0,0. Экструдим на толщину. + // Функция для позиционирования стенки + const placeWall = (shape: THREE.Shape, x: number, y: number, z: number, rotY: number) => { const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - // Центрируем shape по X (чтобы вращать удобно) - geo.translate(-len/2, 0, 0); + // Центрируем пивот по X для удобного вращения, если нужно, или просто сдвигаем + // По умолчанию Shape рисуется от 0,0 в +X,+Y. Extrude идет в +Z. - // Поворачиваем: Сначала "поднимаем" shape вертикально? - // По умолчанию shape в XY плоскости. Extrude идет в Z. - // Нам нужно чтобы стена стояла. - // XY plane -> Wall upright. + // Сдвигаем pivot в центр по X (ширине стенки) + // Нет, проще оперировать от угла. + // 0,0 shape -> это нижний левый угол стенки. - // Сдвигаем на позицию (x, y=thickness, z) - // Y в ThreeJS это "вверх". Стенки начинаются с floor thickness. + geo.translate(0, thickness, 0); // Поднимаем на толщину пола (Y) - // Корректировка пивота - geo.translate(len/2, 0, 0); // Вернули в 0..len по X - geo.translate(-len/2, 0, 0); // Центрируем: -len/2 .. len/2 + // Вращаем вокруг Y + // Внимание: вращение идет вокруг (0,0,0) сцены, поэтому сначала вращаем, потом двигаем + + // 1. Поворот самой геометрии относительно её начала + if (rotY !== 0) { + geo.rotateY(rotY); + } - // Вращение - geo.rotateY(rotY); - // Позиция - geo.translate(pos[0], thickness, pos[2]); // Y = thickness (на полу) + // 2. Перенос на позицию + geo.translate(x, 0, z); + geometries.push(geo); }; - // ! ВАЖНО: Текущая реализация createRectWithHoles рисует прямоугольник 0..W, 0..H в плоскости XY. - // Extrude добавляет глубину Z. - // Стенка "стоит" если Z - это толщина. - - // Front Wall (Z = innerD/2 + thick/2 = depth/2 - thick/2) -> Позиция Z чуть смещена - // Back Wall (Z = -depth/2 + thick/2) - - // Front (Z+) - const geoF = new THREE.ExtrudeGeometry(wallShapeFB, { depth: thickness, bevelEnabled: false }); - geoF.translate(-innerW/2, 0, 0); // Центрируем по X - geoF.translate(0, thickness, innerD/2); // Поднимаем на пол, сдвигаем вперед - geometries.push(geoF); + // Back Wall (Задняя) + // Стоит вдоль X. Позиция: x=-width/2, z=-depth/2. + // Рисуется от 0 до width. + const geoBack = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); + geoBack.translate(-width/2, thickness, -depth/2); // Ставим назад + geometries.push(geoBack); - // Back (Z-) - const geoB = new THREE.ExtrudeGeometry(wallShapeFB, { depth: thickness, bevelEnabled: false }); - geoB.translate(-innerW/2, 0, -thickness); // Центрируем, толщина назад - geoB.translate(0, thickness, -innerD/2); - geometries.push(geoB); + // Front Wall (Передняя) + // Стоит вдоль X. Позиция: x=-width/2, z=depth/2 - thickness. + const geoFront = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); + geoFront.translate(-width/2, thickness, depth/2 - thickness); + geometries.push(geoFront); - // Left (X-) - const geoL = new THREE.ExtrudeGeometry(wallShapeLR, { depth: thickness, bevelEnabled: false }); - geoL.rotateY(Math.PI / 2); // Поворачиваем на 90 - geoL.translate(-width/2, thickness, -depth/2); // Ставим слева - geometries.push(geoL); + // Left Wall (Левая) + // Стоит вдоль Z. Повернута на 90 град. + // Длина = sideWallW. + const geoLeft = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); + geoLeft.rotateY(Math.PI / 2); + // После поворота на 90: +X стал +Z. Начало в 0,0. + // Нам нужно поставить её на x = -width/2, z = -sideWallW/2 (центрировать по глубине) + // С учетом толщины пола и стенок: + geoLeft.translate(-width/2, thickness, -sideWallW/2); + geometries.push(geoLeft); - // Right (X+) - const geoR = new THREE.ExtrudeGeometry(wallShapeLR, { depth: thickness, bevelEnabled: false }); - geoR.rotateY(Math.PI / 2); - geoR.translate(width/2 - thickness, thickness, -depth/2); - geometries.push(geoR); + // Right Wall (Правая) + const geoRight = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); + geoRight.rotateY(Math.PI / 2); + geoRight.translate(width/2 - thickness, thickness, -sideWallW/2); + geometries.push(geoRight); - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (С ДЫРКАМИ) - const limitMap = solveWallLimits(partitions); - + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ + // Используем простую логику, как в редакторе (min/max) partitions.forEach(p => { - const { min: pMin, max: pMax } = limitMap[p.id]; + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + + // Пропускаем слишком короткие или ошибочные if (pMax - pMin < 0.01) return; - const lengthRatio = pMax - pMin; - - // Реальная длина стенки в мм - let wallLen = 0; - let pX = 0, pZ = 0; // Центр стенки - let rotY = 0; + const innerW = width - 2 * thickness; + const innerD = depth - 2 * thickness; + + let partLen = 0; + let posX = 0; + let posZ = 0; + let isVertical = false; if (p.axis === 'x') { - // Вертикальная на экране = Вдоль Z в 3D - wallLen = lengthRatio * innerD; - // Позиция центра - pX = (-innerW / 2) + (innerW * p.offset); - // Начало по Z - const startZ = (-innerD / 2) + (innerD * pMin); - pZ = startZ; - rotY = Math.PI / 2; + // Вертикальная на экране (вдоль Z) + partLen = (pMax - pMin) * innerD; + isVertical = true; + // X координата (центр линии) + posX = (-innerW/2) + (p.offset * innerW); + // Z координата (начало линии) + posZ = (-innerD/2) + (pMin * innerD); } else { - // Горизонтальная на экране = Вдоль X в 3D - wallLen = lengthRatio * innerW; - const startX = (-innerW / 2) + (innerW * pMin); - pX = startX; - pZ = (-innerD / 2) + (innerD * p.offset); - rotY = 0; + // Горизонтальная на экране (вдоль X) + partLen = (pMax - pMin) * innerW; + isVertical = false; + // X координата (начало линии) + posX = (-innerW/2) + (pMin * innerW); + // Z координата (центр линии) + posZ = (-innerD/2) + (p.offset * innerD); } - // Создаем профиль с дырками - const partShape = createRectWithHoles(wallLen, wallH, safeConfig); + // Создаем стенку с дырками + const partShape = createPerforatedShape(partLen, wallH, safeConfig); const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); - // Поворачиваем и ставим на место - // Изначально shape в XY (0..len, 0..height) - if (p.axis === 'x') { - // Нужно повернуть Y 90. + if (isVertical) { partGeo.rotateY(Math.PI / 2); - // После поворота: X -> Z, Y -> Y, Z -> X - // Начало было 0,0,0. Стало 0,0,0. Длина ушла в +Z. - partGeo.translate(pX - thickness/2, thickness, pZ); + // Центрируем толщину: offset - thickness/2 + partGeo.translate(posX - thickness/2, thickness, posZ); } else { - // Вдоль X. Ничего вращать не надо, кроме смещения на толщину - partGeo.translate(pX, thickness, pZ - thickness/2); + // Вдоль X + partGeo.translate(posX, thickness, posZ - thickness/2); } - + geometries.push(partGeo); - // --- FILLETS (Остаются как были, они вертикальные, дырки их не касаются) --- + // --- СКРУГЛЕНИЯ (FILLETS) --- + // Добавляем только если есть примыкание if (p.rounded && radius > 1) { - // Логика галтелей остается прежней (она работает хорошо) const filletR = Math.min(radius, 5); - const filletShape = createConcaveFilletShape(filletR); - - const getNeighborHeight = (pos: number) => { - if (pos < 0.001 || pos > 0.999) return height; - const neighbor = partitions.find(n => { - if (n.axis === p.axis) return false; - const nLims = limitMap[n.id]; - return Math.abs(n.offset - pos) < 0.002 && p.offset >= nLims.min && p.offset <= nLims.max; - }); - return neighbor ? neighbor.height : 0; - }; + const filletShape = createFilletShape(filletR); + const h = p.height; // Пока берем полную высоту, чтобы не усложнять - const hStart = Math.min(p.height, getNeighborHeight(pMin)); - const hEnd = Math.min(p.height, getNeighborHeight(pMax)); - - const addFillet = (x: number, y: number, rot: number, h: number) => { - if (h <= 1) return; + const addFillet = (fx: number, fz: number, rot: number) => { const geo = new THREE.ExtrudeGeometry(filletShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); - geo.rotateY(rot); - geo.translate(x, thickness, y); + geo.rotateX(-Math.PI / 2); // Кладем плашмя + geo.rotateY(rot); // Крутим вокруг оси Y + geo.translate(fx, thickness, fz); geometries.push(geo); }; const t = thickness / 2; - // Координаты для галтелей - if (p.axis === 'x') { - // ... тот же код галтелей - const topZ = (-innerD / 2) + (innerD * pMin); - const botZ = (-innerD / 2) + (innerD * pMax); - const centerX = (-innerW/2) + (innerW * p.offset); - addFillet(centerX - t, topZ, Math.PI, hStart); - addFillet(centerX + t, topZ, -Math.PI / 2, hStart); - addFillet(centerX - t, botZ, Math.PI / 2, hEnd); - addFillet(centerX + t, botZ, 0, hEnd); + if (isVertical) { + // Концы вертикальной стенки (по Z) + const topZ = posZ; // pMin + const botZ = posZ + partLen; // pMax + + // Top junction + addFillet(posX - t, topZ, Math.PI); + addFillet(posX + t, topZ, -Math.PI/2); + // Bottom junction + addFillet(posX - t, botZ, Math.PI/2); + addFillet(posX + t, botZ, 0); } else { - const leftX = (-innerW / 2) + (innerW * pMin); - const rightX = (-innerW / 2) + (innerW * pMax); - const centerZ = (-innerD/2) + (innerD * p.offset); - - addFillet(leftX, centerZ - t, 0, hStart); - addFillet(leftX, centerZ + t, -Math.PI / 2, hStart); - addFillet(rightX, centerZ - t, Math.PI / 2, hEnd); - addFillet(rightX, centerZ + t, Math.PI, hEnd); + // Концы горизонтальной стенки (по X) + const leftX = posX; // pMin + const rightX = posX + partLen; // pMax + + // Left junction + addFillet(leftX, posZ - t, 0); + addFillet(leftX, posZ + t, -Math.PI/2); + // Right junction + addFillet(rightX, posZ - t, Math.PI/2); + addFillet(rightX, posZ + t, Math.PI); } } }); const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1); + if (merged) { + merged.computeVertexNormals(); + return merged; + } + return new THREE.BoxGeometry(1, 1, 1); }; +// Экспорт STL export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); From d3170e79abfe717526dd3f419496ce2ba2d718bd 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: Mon, 12 Jan 2026 00:47:52 +0300 Subject: [PATCH 04/21] 2 --- src/services/geometryGenerator.ts | 316 +++++++++++++++--------------- 1 file changed, 158 insertions(+), 158 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index c1b6d58..673726f 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -10,40 +10,51 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const safeY = Array.isArray(splits?.y) ? splits.y : []; const safeParts = splits?.partitions || {}; - const xPoints = [0, ...[...safeX].sort((a, b) => a - b), 1]; - const yPoints = [0, ...[...safeY].sort((a, b) => a - b), 1]; + // Очистка и сортировка точек реза с удалением дубликатов (защита от лишних ячеек) + const uniqueX = Array.from(new Set([0, ...safeX, 1])).sort((a, b) => a - b); + const uniqueY = Array.from(new Set([0, ...safeY, 1])).sort((a, b) => a - b); let partCounter = 1; - for (let i = 0; i < xPoints.length - 1; i++) { - 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 < 5 || rawD < 5) continue; + for (let i = 0; i < uniqueX.length - 1; i++) { + for (let j = 0; j < uniqueY.length - 1; j++) { + const x1 = uniqueX[i]; + const x2 = uniqueX[i+1]; + const y1 = uniqueY[j]; + const y2 = uniqueY[j+1]; - const rawX = xPoints[i] * config.drawer.width; - const rawY = yPoints[j] * config.drawer.depth; + // Пропускаем микро-сдвиги (меньше 1мм) + if (Math.abs(x2 - x1) < 0.001 || Math.abs(y2 - y1) < 0.001) continue; + + const rawW = (x2 - x1) * config.drawer.width; + const rawD = (y2 - y1) * config.drawer.depth; + + const rawX = x1 * config.drawer.width; + const rawY = y1 * config.drawer.depth; + + // Ключ для поиска перегородок берем из оригинальных индексов (тут упрощение, предполагаем соответствие) + // Для точности лучше искать по координатам, но пока оставим ключ const internalPartitions = safeParts[`${i}-${j}`] || []; - // Внутренний отступ для визуализации "объема" (цветных кубиков) - // Чтобы они не сливались со стенками - const gap = config.wallThickness + 0.5; + // Отступ для визуализации "кубиков" (gap), чтобы они не слипались в превью + const gap = config.wallThickness / 2; - const realWidth = rawW - gap * 2; - const realDepth = rawD - gap * 2; - const realX = rawX + gap; - const realY = rawY + gap; + const realWidth = rawW - config.printerTolerance; + const realDepth = rawD - config.printerTolerance; + const realX = rawX + (config.printerTolerance / 2); + const realY = rawY + (config.printerTolerance / 2); + + if (realWidth < 2 || realDepth < 2) continue; parts.push({ id: `part-${partCounter}`, - name: `Ячейка ${i+1}-${j+1}`, - width: Math.max(1, realWidth), - depth: Math.max(1, realDepth), + name: `Ячейка ${partCounter}`, + width: realWidth, + depth: realDepth, height: config.drawer.height, x: realX, y: realY, - color: `hsl(${Math.random() * 360}, 70%, 50%)`, + color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, // Золотое сечение для цветов internalPartitions: internalPartitions }); partCounter++; @@ -52,66 +63,68 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ФОРМ С ОТВЕРСТИЯМИ --- +// --- ГЕОМЕТРИЯ --- -// Создает форму прямоугольника с отверстиями по паттерну +// Прямоугольник с отверстиями (для стен) const createPerforatedShape = (width: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); - // Рисуем внешний контур (CCW) + // Внешний контур (Counter-Clockwise) shape.moveTo(0, 0); shape.lineTo(width, 0); shape.lineTo(width, height); shape.lineTo(0, height); shape.lineTo(0, 0); - // Если перфорация выключена или стенка слишком маленькая, возвращаем сплошной if (!config.perforation?.enabled || width < 15 || height < 15) return shape; const { pattern, diameter, spacing } = config.perforation; const step = diameter + Math.max(2, spacing); - - // Отступы от краев (чтобы не портить прочность) - const margin = 6; + const margin = 4; // Отступ от краев стенки - // Эффективная область для дырок const effW = width - margin * 2; const effH = height - margin * 2; if (effW <= 0 || effH <= 0) return shape; - // Расчет сетки - const rowHeight = pattern === 'circle' ? step : step * 0.866; + const rowH = pattern === 'circle' ? step : step * 0.866; const cols = Math.floor(effW / step); - const rows = Math.floor(effH / rowHeight); + const rows = Math.floor(effH / rowH); - // Центрирование const startX = margin + (effW - (cols - 1) * step) / 2; - const startY = margin + (effH - (rows - 1) * rowHeight) / 2; + const startY = margin + (effH - (rows - 1) * rowH) / 2; for (let j = 0; j < rows; j++) { const isOdd = j % 2 !== 0; - const y = startY + j * rowHeight; + const y = startY + j * rowH; for (let i = 0; i < cols; i++) { let x = startX + i * step; if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) x += step / 2; - // Проверка, что отверстие внутри (с запасом на радиус) - const r = diameter / 2; - if (x - r < margin || x + r > width - margin || y - r < margin || y + r > height - margin) continue; + // Проверка границ + if (x - diameter/2 < margin || x + diameter/2 > width - margin || + y - diameter/2 < margin || y + diameter/2 > height - margin) continue; const hole = new THREE.Path(); + const r = diameter / 2; + + // ВАЖНО: Отверстия должны рисоваться по ЧАСОВОЙ стрелке (Clockwise), + // иначе Three.js не вырежет их, а зальет. if (pattern === 'circle') { - hole.absarc(x, y, r, 0, Math.PI * 2, true); // CW для отверстий + // aClockwise = false + hole.absarc(x, y, r, 0, Math.PI * 2, false); } else if (pattern === 'hexagon') { for (let k = 0; k < 6; k++) { - const angle = (k * 60 + 30) * Math.PI / 180; // 30 deg offset for flat top + const angle = (k * 60 + 30) * Math.PI / 180; const px = x + r * Math.cos(angle); const py = y + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } - hole.closePath(); + // Для многоугольников порядок зависит от порядка точек. + // Создаем их в нужном порядке или используем reverse() если не вырезается. + // Текущий порядок CCW, нужно CW? Проверим на практике. Обычно Path AutoClose работает. + // Если возникнут проблемы, поменяем порядок k (5..0). } else if (pattern === 'triangle') { const rot = isOdd ? 180 : 0; for (let k = 0; k < 3; k++) { @@ -120,21 +133,20 @@ const createPerforatedShape = (width: number, height: number, config: AppConfig) const py = y + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } - hole.closePath(); } + hole.closePath(); shape.holes.push(hole); } } - return shape; }; -// Форма пола со скругленными углами +// Пол со скруглениями (Сплошной) const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; const y = -depth / 2; - const r = Math.min(radius, width / 2, depth / 2); + const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); if (r <= 0.1) { shape.moveTo(x, y); @@ -156,8 +168,7 @@ const createFloorShape = (width: number, depth: number, radius: number): THREE.S return shape; }; -// Галтель (вогнутый уголок) -const createFilletShape = (radius: number): THREE.Shape => { +const createConcaveFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); shape.moveTo(0, 0); shape.lineTo(radius, 0); @@ -166,7 +177,7 @@ const createFilletShape = (radius: number): THREE.Shape => { return shape; }; -// --- ГЛАВНАЯ ФУНКЦИЯ --- +// --- СБОРКА БИНА --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig @@ -174,183 +185,172 @@ export const createBinGeometry = ( const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - // 1. ПОЛ (Сплошной) + // 1. ПОЛ const floorShape = createFloorShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // Кладем на плоскость XZ + floorGeo.rotateX(-Math.PI / 2); // XZ plane geometries.push(floorGeo); - // 2. ВНЕШНИЕ СТЕНКИ - // Строим их "лежа" в плоскости XY, а потом поворачиваем и ставим на место. - // Это позволяет использовать 2D логику для отверстий. - + // 2. СТЕНКИ (Внешние) const wallH = height - thickness; - const sideWallW = depth - (2 * thickness); // Боковые стенки встанут МЕЖДУ передней и задней + const innerW = width - 2 * thickness; + const innerD = depth - 2 * thickness; - // Передняя и Задняя (Полная ширина) - const shapeFB = createPerforatedShape(width, wallH, safeConfig); - // Левая и Правая (Укороченные, чтобы встать в паз) - const shapeLR = createPerforatedShape(sideWallW, wallH, safeConfig); + // Формы стен с перфорацией (2D профиль) + const shapeFrontBack = createPerforatedShape(innerW, wallH, safeConfig); + const shapeLeftRight = createPerforatedShape(depth, wallH, safeConfig); // Полная глубина для боковин - // Функция для позиционирования стенки - const placeWall = (shape: THREE.Shape, x: number, y: number, z: number, rotY: number) => { + // Helper для установки стены + const addWall = (shape: THREE.Shape, x: number, z: number, rotationY: number, offsetZ: number = 0) => { const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - // Центрируем пивот по X для удобного вращения, если нужно, или просто сдвигаем - // По умолчанию Shape рисуется от 0,0 в +X,+Y. Extrude идет в +Z. - // Сдвигаем pivot в центр по X (ширине стенки) - // Нет, проще оперировать от угла. - // 0,0 shape -> это нижний левый угол стенки. + // По умолчанию Shape 0..W, 0..H. Extrude 0..Thick (Z). + // Центрируем по высоте (ставим на пол) - geo.translate(0, thickness, 0); // Поднимаем на толщину пола (Y) - - // Вращаем вокруг Y - // Внимание: вращение идет вокруг (0,0,0) сцены, поэтому сначала вращаем, потом двигаем - - // 1. Поворот самой геометрии относительно её начала - if (rotY !== 0) { - geo.rotateY(rotY); - } - - // 2. Перенос на позицию - geo.translate(x, 0, z); + if (rotationY !== 0) geo.rotateY(rotationY); + // Позиционирование + geo.translate(x, thickness, z); geometries.push(geo); }; - // Back Wall (Задняя) - // Стоит вдоль X. Позиция: x=-width/2, z=-depth/2. - // Рисуется от 0 до width. - const geoBack = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoBack.translate(-width/2, thickness, -depth/2); // Ставим назад - geometries.push(geoBack); + // Front (Спереди, вдоль X) + const geoF = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); + // Центрируем по X (-innerW/2) + geoF.translate(-innerW/2, thickness, depth/2 - thickness); + geometries.push(geoF); - // Front Wall (Передняя) - // Стоит вдоль X. Позиция: x=-width/2, z=depth/2 - thickness. - const geoFront = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoFront.translate(-width/2, thickness, depth/2 - thickness); - geometries.push(geoFront); + // Back (Сзади, вдоль X) + const geoB = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); + // Сдвигаем depth mesh'а назад + geoB.translate(0, 0, -thickness); + geoB.translate(-innerW/2, thickness, -depth/2 + thickness); + geometries.push(geoB); - // Left Wall (Левая) - // Стоит вдоль Z. Повернута на 90 град. - // Длина = sideWallW. - const geoLeft = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoLeft.rotateY(Math.PI / 2); - // После поворота на 90: +X стал +Z. Начало в 0,0. - // Нам нужно поставить её на x = -width/2, z = -sideWallW/2 (центрировать по глубине) - // С учетом толщины пола и стенок: - geoLeft.translate(-width/2, thickness, -sideWallW/2); - geometries.push(geoLeft); + // Left (Слева, вдоль Z) + const geoL = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); + geoL.rotateY(Math.PI / 2); // Поворот +90. X->Z. (Len, 0, 0) -> (0, 0, -Len) ? Нет, (0,0,-Len) + // Коррекция позиции + geoL.translate(-width/2, thickness, -depth/2); + geometries.push(geoL); - // Right Wall (Правая) - const geoRight = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoRight.rotateY(Math.PI / 2); - geoRight.translate(width/2 - thickness, thickness, -sideWallW/2); - geometries.push(geoRight); + // Right (Справа, вдоль Z) + const geoR = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); + geoR.rotateY(Math.PI / 2); + geoR.translate(width/2 - thickness, thickness, -depth/2); + geometries.push(geoR); // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - // Используем простую логику, как в редакторе (min/max) + // Используем жесткие координаты min/max, без попыток угадать (Solver удален для соответствия 2D) partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - // Пропускаем слишком короткие или ошибочные if (pMax - pMin < 0.01) return; - const innerW = width - 2 * thickness; - const innerD = depth - 2 * thickness; - + const lengthRatio = pMax - pMin; let partLen = 0; let posX = 0; let posZ = 0; - let isVertical = false; + let isVertical = false; // Vertical on 2D screen = Along Z axis in 3D if (p.axis === 'x') { - // Вертикальная на экране (вдоль Z) - partLen = (pMax - pMin) * innerD; + // Вертикальная на экране -> Вдоль Z isVertical = true; - // X координата (центр линии) - posX = (-innerW/2) + (p.offset * innerW); - // Z координата (начало линии) + partLen = lengthRatio * innerD; + // Центр по X + posX = (-innerW/2) + (p.offset * innerW); + // Начало по Z posZ = (-innerD/2) + (pMin * innerD); } else { - // Горизонтальная на экране (вдоль X) - partLen = (pMax - pMin) * innerW; + // Горизонтальная на экране -> Вдоль X isVertical = false; - // X координата (начало линии) + partLen = lengthRatio * innerW; + // Начало по X posX = (-innerW/2) + (pMin * innerW); - // Z координата (центр линии) + // Центр по Z posZ = (-innerD/2) + (p.offset * innerD); } - // Создаем стенку с дырками + // Создаем профиль с дырками const partShape = createPerforatedShape(partLen, wallH, safeConfig); const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); if (isVertical) { - partGeo.rotateY(Math.PI / 2); - // Центрируем толщину: offset - thickness/2 - partGeo.translate(posX - thickness/2, thickness, posZ); + // Поворот чтобы шла вдоль Z + partGeo.rotateY(Math.PI / 2); + // При повороте +90 вокруг (0,0,0), положительный X уходит в отрицательный Z (или положительный, зависит от системы) + // ThreeJS: Right handed. Y up. + // Shape 0..Len по X. Rotate Y 90 -> 0..-Len по Z. + // Нам нужно поставить начало (0,0) в (posX, floor, posZ). + // Но из-за поворота "длина" ушла в -Z. Значит posZ - это "верхняя" точка? + // Нет, в 2D Y идет вниз. min - это верх. max - это низ. + // В 3D Z идет "на нас" (обычно). minZ - зад, maxZ - перед. + // Если min=0 (верх в 2D) -> -depth/2 (зад в 3D). + // Стенка идет от зада к переду. Значит Z растет. + // Нам нужен поворот -90 (-PI/2), чтобы X перешел в +Z. + partGeo.rotateY(-Math.PI / 2); + + // Центрируем толщину по X + partGeo.translate(posX + thickness/2, thickness, posZ); + // Сдвиг на thickness/2 может зависеть от того, как экструдилось (0..thick или -thick/2..thick/2) + // Extrude создает 0..depth. После поворота это становится X? Нет. + // Extrude по Z локальному. Rotate Y крутит оси X и Z. + // Изначально: Shape в XY. Extrude в Z. + // Rotate Y -90: + // X -> Z. Y -> Y. Z -> -X. + // Толщина ушла в -X. Длина ушла в +Z. + // Позиция: StartX, StartY, StartZ. } else { - // Вдоль X + // Вдоль X. Поворот не нужен. + // Толщина уходит в +Z. + // Нам нужно центрировать толщину вокруг posZ. partGeo.translate(posX, thickness, posZ - thickness/2); } geometries.push(partGeo); - // --- СКРУГЛЕНИЯ (FILLETS) --- - // Добавляем только если есть примыкание + // --- ГАЛТЕЛИ (FILLETS) --- if (p.rounded && radius > 1) { const filletR = Math.min(radius, 5); - const filletShape = createFilletShape(filletR); - const h = p.height; // Пока берем полную высоту, чтобы не усложнять + const filletShape = createConcaveFilletShape(filletR); + const h = p.height; // Упрощаем высоту до полной, чтобы избежать глюков - const addFillet = (fx: number, fz: number, rot: number) => { + const addFillet = (x: number, z: number, rot: number) => { const geo = new THREE.ExtrudeGeometry(filletShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); // Кладем плашмя - geo.rotateY(rot); // Крутим вокруг оси Y - geo.translate(fx, thickness, fz); + geo.rotateX(-Math.PI / 2); + geo.rotateY(rot); + geo.translate(x, thickness, z); geometries.push(geo); }; const t = thickness / 2; if (isVertical) { - // Концы вертикальной стенки (по Z) - const topZ = posZ; // pMin - const botZ = posZ + partLen; // pMax - - // Top junction - addFillet(posX - t, topZ, Math.PI); - addFillet(posX + t, topZ, -Math.PI/2); - // Bottom junction - addFillet(posX - t, botZ, Math.PI/2); - addFillet(posX + t, botZ, 0); + const startZ = posZ; + const endZ = posZ + partLen; + // Стыки + addFillet(posX - t, startZ, Math.PI); + addFillet(posX + t, startZ, -Math.PI/2); + addFillet(posX - t, endZ, Math.PI/2); + addFillet(posX + t, endZ, 0); } else { - // Концы горизонтальной стенки (по X) - const leftX = posX; // pMin - const rightX = posX + partLen; // pMax - - // Left junction - addFillet(leftX, posZ - t, 0); - addFillet(leftX, posZ + t, -Math.PI/2); - // Right junction - addFillet(rightX, posZ - t, Math.PI/2); - addFillet(rightX, posZ + t, Math.PI); + const startX = posX; + const endX = posX + partLen; + addFillet(startX, posZ - t, 0); + addFillet(startX, posZ + t, -Math.PI/2); + addFillet(endX, posZ - t, Math.PI/2); + addFillet(endX, posZ + t, Math.PI); } } }); const merged = mergeBufferGeometries(geometries); - if (merged) { - merged.computeVertexNormals(); - return merged; - } - return new THREE.BoxGeometry(1, 1, 1); + if (merged) merged.computeVertexNormals(); + return merged || new THREE.BoxGeometry(1, 1, 1); }; -// Экспорт STL export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); From 2fee39afce000b064f567d87275a04a77f56a4d0 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: Mon, 12 Jan 2026 00:59:56 +0300 Subject: [PATCH 05/21] 3 --- src/services/geometryGenerator.ts | 319 ++++++++++++++---------------- 1 file changed, 151 insertions(+), 168 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 673726f..cdff19c 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -4,15 +4,26 @@ import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- +// Очистка дубликатов и сортировка точек (убирает фантомные ячейки) +const cleanPoints = (points: number[]) => { + const sorted = [...points].sort((a, b) => a - b); + const unique = [sorted[0]]; + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] - unique[unique.length - 1] > 0.005) { // Игнорируем точки ближе 0.5% + unique.push(sorted[i]); + } + } + return unique; +}; + export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; - const safeX = Array.isArray(splits?.x) ? splits.x : []; - const safeY = Array.isArray(splits?.y) ? splits.y : []; - const safeParts = splits?.partitions || {}; - - // Очистка и сортировка точек реза с удалением дубликатов (защита от лишних ячеек) - const uniqueX = Array.from(new Set([0, ...safeX, 1])).sort((a, b) => a - b); - const uniqueY = Array.from(new Set([0, ...safeY, 1])).sort((a, b) => a - b); + // Используем защищенные массивы + const rawX = Array.isArray(splits?.x) ? splits.x : []; + const rawY = Array.isArray(splits?.y) ? splits.y : []; + + const uniqueX = cleanPoints([0, ...rawX, 1]); + const uniqueY = cleanPoints([0, ...rawY, 1]); let partCounter = 1; @@ -23,39 +34,25 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; - // Пропускаем микро-сдвиги (меньше 1мм) - if (Math.abs(x2 - x1) < 0.001 || Math.abs(y2 - y1) < 0.001) continue; + const w = (x2 - x1) * config.drawer.width; + const d = (y2 - y1) * config.drawer.depth; - const rawW = (x2 - x1) * config.drawer.width; - const rawD = (y2 - y1) * config.drawer.depth; - - const rawX = x1 * config.drawer.width; - const rawY = y1 * config.drawer.depth; - - // Ключ для поиска перегородок берем из оригинальных индексов (тут упрощение, предполагаем соответствие) - // Для точности лучше искать по координатам, но пока оставим ключ - const internalPartitions = safeParts[`${i}-${j}`] || []; + // Пропускаем слишком маленькие или некорректные объемы + if (w < 2 || d < 2) continue; - // Отступ для визуализации "кубиков" (gap), чтобы они не слипались в превью - const gap = config.wallThickness / 2; - - const realWidth = rawW - config.printerTolerance; - const realDepth = rawD - config.printerTolerance; - const realX = rawX + (config.printerTolerance / 2); - const realY = rawY + (config.printerTolerance / 2); - - if (realWidth < 2 || realDepth < 2) continue; + // Визуальный отступ, чтобы кубики не слипались со стенками + const gap = config.wallThickness / 2 + 0.5; parts.push({ id: `part-${partCounter}`, name: `Ячейка ${partCounter}`, - width: realWidth, - depth: realDepth, + width: Math.max(1, w - gap * 2), + depth: Math.max(1, d - gap * 2), height: config.drawer.height, - x: realX, - y: realY, - color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, // Золотое сечение для цветов - internalPartitions: internalPartitions + x: (x1 * config.drawer.width) + gap, + y: (y1 * config.drawer.depth) + gap, + color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, + internalPartitions: [] // Внутренние перегородки обрабатываются отдельно в createBinGeometry }); partCounter++; } @@ -63,28 +60,30 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ --- +// --- ГЕОМЕТРИЯ СТЕН И ОТВЕРСТИЙ --- -// Прямоугольник с отверстиями (для стен) -const createPerforatedShape = (width: number, height: number, config: AppConfig): THREE.Shape => { +const createPerforatedWallShape = (length: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); - // Внешний контур (Counter-Clockwise) + + // 1. Внешний контур: Против часовой стрелки (CCW) + // (0,0) -> (len,0) -> (len,h) -> (0,h) -> (0,0) shape.moveTo(0, 0); - shape.lineTo(width, 0); - shape.lineTo(width, height); + shape.lineTo(length, 0); + shape.lineTo(length, height); shape.lineTo(0, height); shape.lineTo(0, 0); - if (!config.perforation?.enabled || width < 15 || height < 15) return shape; + // Если перфорация выключена или стена слишком мала, возвращаем целую + if (!config.perforation?.enabled || length < 15 || height < 15) return shape; const { pattern, diameter, spacing } = config.perforation; const step = diameter + Math.max(2, spacing); - const margin = 4; // Отступ от краев стенки + const margin = 4; // Отступ от края стенки - const effW = width - margin * 2; + const effW = length - margin * 2; const effH = height - margin * 2; - if (effW <= 0 || effH <= 0) return shape; + if (effW <= diameter || effH <= diameter) return shape; const rowH = pattern === 'circle' ? step : step * 0.866; const cols = Math.floor(effW / step); @@ -93,61 +92,67 @@ const createPerforatedShape = (width: number, height: number, config: AppConfig) const startX = margin + (effW - (cols - 1) * step) / 2; const startY = margin + (effH - (rows - 1) * rowH) / 2; + const holes: THREE.Path[] = []; + for (let j = 0; j < rows; j++) { const isOdd = j % 2 !== 0; - const y = startY + j * rowH; + const cy = startY + j * rowH; for (let i = 0; i < cols; i++) { - let x = startX + i * step; - if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) x += step / 2; + let cx = startX + i * step; + if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; - // Проверка границ - if (x - diameter/2 < margin || x + diameter/2 > width - margin || - y - diameter/2 < margin || y + diameter/2 > height - margin) continue; + // Проверка выхода за границы + if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || + cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; const hole = new THREE.Path(); const r = diameter / 2; - // ВАЖНО: Отверстия должны рисоваться по ЧАСОВОЙ стрелке (Clockwise), - // иначе Three.js не вырежет их, а зальет. + // 2. Отверстия: Строго по часовой стрелке (CW) + // Это критически важно для корректного отображения и экспорта! if (pattern === 'circle') { - // aClockwise = false - hole.absarc(x, y, r, 0, Math.PI * 2, false); - } else if (pattern === 'hexagon') { + // aClockwise = true (CW) + hole.absarc(cx, cy, r, 0, Math.PI * 2, true); + } + else if (pattern === 'hexagon') { + // Рисуем 6 точек по часовой стрелке for (let k = 0; k < 6; k++) { - const angle = (k * 60 + 30) * Math.PI / 180; - const px = x + r * Math.cos(angle); - const py = y + r * Math.sin(angle); + // -k (отрицательный шаг) обеспечивает CW порядок + const angle = (-k * 60 + 90) * Math.PI / 180; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } - // Для многоугольников порядок зависит от порядка точек. - // Создаем их в нужном порядке или используем reverse() если не вырезается. - // Текущий порядок CCW, нужно CW? Проверим на практике. Обычно Path AutoClose работает. - // Если возникнут проблемы, поменяем порядок k (5..0). - } else if (pattern === 'triangle') { + hole.closePath(); + } + else if (pattern === 'triangle') { const rot = isOdd ? 180 : 0; + // Рисуем 3 точки по часовой стрелке for (let k = 0; k < 3; k++) { - const angle = (k * 120 - 90 + rot) * Math.PI / 180; - const px = x + r * Math.cos(angle); - const py = y + r * Math.sin(angle); + const angle = (-k * 120 + 90 + rot) * Math.PI / 180; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } + hole.closePath(); } - hole.closePath(); - shape.holes.push(hole); + holes.push(hole); } } + shape.holes = holes; return shape; }; -// Пол со скруглениями (Сплошной) +// Пол (всегда сплошной) const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; const y = -depth / 2; const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); + // CCW Order if (r <= 0.1) { shape.moveTo(x, y); shape.lineTo(x + width, y); @@ -168,16 +173,18 @@ const createFloorShape = (width: number, depth: number, radius: number): THREE.S return shape; }; -const createConcaveFilletShape = (radius: number): THREE.Shape => { +// Галтель (вогнутая) +const createFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); shape.moveTo(0, 0); shape.lineTo(radius, 0); + // Дуга CW для выреза, но так как это тело вращения/экструзии, тут важна форма профиля shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); shape.lineTo(0, 0); return shape; }; -// --- СБОРКА БИНА --- +// --- СБОРКА МОДЕЛИ --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig @@ -188,124 +195,92 @@ export const createBinGeometry = ( // 1. ПОЛ const floorShape = createFloorShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // XZ plane + floorGeo.rotateX(-Math.PI / 2); // Лежит в плоскости XZ geometries.push(floorGeo); - // 2. СТЕНКИ (Внешние) - const wallH = height - thickness; + // Размеры внутреннего пространства const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; + const wallH = height - thickness; - // Формы стен с перфорацией (2D профиль) - const shapeFrontBack = createPerforatedShape(innerW, wallH, safeConfig); - const shapeLeftRight = createPerforatedShape(depth, wallH, safeConfig); // Полная глубина для боковин - - // Helper для установки стены - const addWall = (shape: THREE.Shape, x: number, z: number, rotationY: number, offsetZ: number = 0) => { - const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - - // По умолчанию Shape 0..W, 0..H. Extrude 0..Thick (Z). - // Центрируем по высоте (ставим на пол) - - if (rotationY !== 0) geo.rotateY(rotationY); - - // Позиционирование - geo.translate(x, thickness, z); - geometries.push(geo); - }; - - // Front (Спереди, вдоль X) - const geoF = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); - // Центрируем по X (-innerW/2) - geoF.translate(-innerW/2, thickness, depth/2 - thickness); + // 2. ВНЕШНИЕ СТЕНКИ + // Мы создаем их вертикально. Базовая форма рисуется в XY (Width x Height), потом вращается. + + // -- Передняя и Задняя (Вдоль X) -- + const shapeFB = createPerforatedWallShape(innerW, wallH, safeConfig); + + // Front + const geoF = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); + geoF.translate(-innerW/2, thickness, depth/2 - thickness); // Центр X, на полу, край Z geometries.push(geoF); - // Back (Сзади, вдоль X) - const geoB = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); - // Сдвигаем depth mesh'а назад - geoB.translate(0, 0, -thickness); - geoB.translate(-innerW/2, thickness, -depth/2 + thickness); + // Back + const geoB = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); + geoB.translate(-innerW/2, thickness, -depth/2); // Центр X, на полу, задний край Z geometries.push(geoB); - // Left (Слева, вдоль Z) - const geoL = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); - geoL.rotateY(Math.PI / 2); // Поворот +90. X->Z. (Len, 0, 0) -> (0, 0, -Len) ? Нет, (0,0,-Len) - // Коррекция позиции + // -- Левая и Правая (Вдоль Z) -- + // Они идут по всей глубине (depth), перекрывая торцы передней/задней + const shapeLR = createPerforatedWallShape(depth, wallH, safeConfig); + + // Left + const geoL = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); + geoL.rotateY(Math.PI / 2); // Поворот +90 (теперь идет вдоль Z) + // При повороте +90 вокруг (0,0,0): X+ -> Z-. Начало (0,0) остается (0,0). + // Нам нужно сместить начало в (X=-width/2, Z=-depth/2) geoL.translate(-width/2, thickness, -depth/2); geometries.push(geoL); - // Right (Справа, вдоль Z) - const geoR = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); + // Right + const geoR = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); geoR.rotateY(Math.PI / 2); geoR.translate(width/2 - thickness, thickness, -depth/2); geometries.push(geoR); // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - // Используем жесткие координаты min/max, без попыток угадать (Solver удален для соответствия 2D) partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; + // Игнорируем некорректные if (pMax - pMin < 0.01) return; - const lengthRatio = pMax - pMin; - let partLen = 0; + let length = 0; let posX = 0; let posZ = 0; - let isVertical = false; // Vertical on 2D screen = Along Z axis in 3D + let isVert = false; if (p.axis === 'x') { - // Вертикальная на экране -> Вдоль Z - isVertical = true; - partLen = lengthRatio * innerD; + // Вертикальная на 2D-схеме (идет вдоль Z в 3D) + isVert = true; + length = (pMax - pMin) * innerD; // Центр по X posX = (-innerW/2) + (p.offset * innerW); // Начало по Z posZ = (-innerD/2) + (pMin * innerD); } else { - // Горизонтальная на экране -> Вдоль X - isVertical = false; - partLen = lengthRatio * innerW; + // Горизонтальная на 2D-схеме (идет вдоль X в 3D) + isVert = false; + length = (pMax - pMin) * innerW; // Начало по X posX = (-innerW/2) + (pMin * innerW); // Центр по Z posZ = (-innerD/2) + (p.offset * innerD); } - // Создаем профиль с дырками - const partShape = createPerforatedShape(partLen, wallH, safeConfig); + // Генерируем форму с дырками + const partShape = createPerforatedWallShape(length, wallH, safeConfig); const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); - if (isVertical) { - // Поворот чтобы шла вдоль Z + if (isVert) { + // Поворачиваем вдоль Z partGeo.rotateY(Math.PI / 2); - // При повороте +90 вокруг (0,0,0), положительный X уходит в отрицательный Z (или положительный, зависит от системы) - // ThreeJS: Right handed. Y up. - // Shape 0..Len по X. Rotate Y 90 -> 0..-Len по Z. - // Нам нужно поставить начало (0,0) в (posX, floor, posZ). - // Но из-за поворота "длина" ушла в -Z. Значит posZ - это "верхняя" точка? - // Нет, в 2D Y идет вниз. min - это верх. max - это низ. - // В 3D Z идет "на нас" (обычно). minZ - зад, maxZ - перед. - // Если min=0 (верх в 2D) -> -depth/2 (зад в 3D). - // Стенка идет от зада к переду. Значит Z растет. - // Нам нужен поворот -90 (-PI/2), чтобы X перешел в +Z. - partGeo.rotateY(-Math.PI / 2); - - // Центрируем толщину по X - partGeo.translate(posX + thickness/2, thickness, posZ); - // Сдвиг на thickness/2 может зависеть от того, как экструдилось (0..thick или -thick/2..thick/2) - // Extrude создает 0..depth. После поворота это становится X? Нет. - // Extrude по Z локальному. Rotate Y крутит оси X и Z. - // Изначально: Shape в XY. Extrude в Z. - // Rotate Y -90: - // X -> Z. Y -> Y. Z -> -X. - // Толщина ушла в -X. Длина ушла в +Z. - // Позиция: StartX, StartY, StartZ. + // Смещаем. Учитываем толщину, чтобы центрировать по линии реза. + partGeo.translate(posX - thickness/2, thickness, posZ); } else { // Вдоль X. Поворот не нужен. - // Толщина уходит в +Z. - // Нам нужно центрировать толщину вокруг posZ. + // Смещаем. partGeo.translate(posX, thickness, posZ - thickness/2); } @@ -313,42 +288,50 @@ export const createBinGeometry = ( // --- ГАЛТЕЛИ (FILLETS) --- if (p.rounded && radius > 1) { - const filletR = Math.min(radius, 5); - const filletShape = createConcaveFilletShape(filletR); - const h = p.height; // Упрощаем высоту до полной, чтобы избежать глюков + const fR = Math.min(radius, 5); + const fShape = createFilletShape(fR); + const h = p.height; - const addFillet = (x: number, z: number, rot: number) => { - const geo = new THREE.ExtrudeGeometry(filletShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); - geo.rotateY(rot); - geo.translate(x, thickness, z); + const addFillet = (fx: number, fz: number, rot: number) => { + const geo = new THREE.ExtrudeGeometry(fShape, { depth: h, bevelEnabled: false }); + geo.rotateX(-Math.PI / 2); // Кладем плашмя + geo.rotateY(rot); // Крутим + geo.translate(fx, thickness, fz); geometries.push(geo); }; const t = thickness / 2; - if (isVertical) { - const startZ = posZ; - const endZ = posZ + partLen; - // Стыки - addFillet(posX - t, startZ, Math.PI); - addFillet(posX + t, startZ, -Math.PI/2); - addFillet(posX - t, endZ, Math.PI/2); - addFillet(posX + t, endZ, 0); + if (isVert) { + const zStart = posZ; + const zEnd = posZ + length; + // Top junction + addFillet(posX - t, zStart, Math.PI); + addFillet(posX + t, zStart, -Math.PI/2); + // Bottom junction + addFillet(posX - t, zEnd, Math.PI/2); + addFillet(posX + t, zEnd, 0); } else { - const startX = posX; - const endX = posX + partLen; - addFillet(startX, posZ - t, 0); - addFillet(startX, posZ + t, -Math.PI/2); - addFillet(endX, posZ - t, Math.PI/2); - addFillet(endX, posZ + t, Math.PI); + const xStart = posX; + const xEnd = posX + length; + // Left junction + addFillet(xStart, posZ - t, 0); + addFillet(xStart, posZ + t, -Math.PI/2); + // Right junction + addFillet(xEnd, posZ - t, Math.PI/2); + addFillet(xEnd, posZ + t, Math.PI); } } }); const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1); + // Пересчет нормалей критичен для правильного освещения (убирает "прозрачность") + if (merged) { + merged.computeVertexNormals(); + return merged; + } + + return new THREE.BoxGeometry(1, 1, 1); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { From 3d79abf4d45796035e9ebc68b769dede93fdc10c 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: Mon, 12 Jan 2026 01:07:55 +0300 Subject: [PATCH 06/21] 4 --- src/services/geometryGenerator.ts | 274 +++++++++++++++--------------- 1 file changed, 139 insertions(+), 135 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index cdff19c..77dc906 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,28 +2,37 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- +// --- CLEANUP UTILS --- -// Очистка дубликатов и сортировка точек (убирает фантомные ячейки) -const cleanPoints = (points: number[]) => { - const sorted = [...points].sort((a, b) => a - b); - const unique = [sorted[0]]; - for (let i = 1; i < sorted.length; i++) { - if (sorted[i] - unique[unique.length - 1] > 0.005) { // Игнорируем точки ближе 0.5% - unique.push(sorted[i]); +// Удаляет дублирующиеся перегородки (фантомы) +const deduplicatePartitions = (partitions: Partition[]): Partition[] => { + const unique: Partition[] = []; + const seen = new Set(); + + partitions.forEach(p => { + // Округляем координаты для создания уникального ключа + const k = `${p.axis}-${p.offset.toFixed(3)}-${p.min?.toFixed(3)}-${p.max?.toFixed(3)}`; + if (!seen.has(k)) { + seen.add(k); + unique.push(p); } - } + }); return unique; }; +// Очистка точек для генерации цветных объемов +const cleanPoints = (points: number[]) => { + return Array.from(new Set(points.map(p => parseFloat(p.toFixed(3))))).sort((a, b) => a - b); +}; + +// --- CALCULATE VOLUMES (ЦВЕТНЫЕ КУБИКИ) --- export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; - // Используем защищенные массивы - const rawX = Array.isArray(splits?.x) ? splits.x : []; - const rawY = Array.isArray(splits?.y) ? splits.y : []; + const safeX = Array.isArray(splits?.x) ? splits.x : []; + const safeY = Array.isArray(splits?.y) ? splits.y : []; - const uniqueX = cleanPoints([0, ...rawX, 1]); - const uniqueY = cleanPoints([0, ...rawY, 1]); + const uniqueX = cleanPoints([0, ...safeX, 1]); + const uniqueY = cleanPoints([0, ...safeY, 1]); let partCounter = 1; @@ -37,22 +46,21 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const w = (x2 - x1) * config.drawer.width; const d = (y2 - y1) * config.drawer.depth; - // Пропускаем слишком маленькие или некорректные объемы if (w < 2 || d < 2) continue; - // Визуальный отступ, чтобы кубики не слипались со стенками - const gap = config.wallThickness / 2 + 0.5; + // Отступ для визуализации (gap) + const gap = config.wallThickness / 2 + 0.2; parts.push({ id: `part-${partCounter}`, name: `Ячейка ${partCounter}`, width: Math.max(1, w - gap * 2), depth: Math.max(1, d - gap * 2), - height: config.drawer.height, + height: config.drawer.height - config.wallThickness, x: (x1 * config.drawer.width) + gap, y: (y1 * config.drawer.depth) + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, - internalPartitions: [] // Внутренние перегородки обрабатываются отдельно в createBinGeometry + internalPartitions: [] }); partCounter++; } @@ -60,25 +68,24 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ СТЕН И ОТВЕРСТИЙ --- +// --- SHAPE GENERATION (PERFORATION) --- -const createPerforatedWallShape = (length: number, height: number, config: AppConfig): THREE.Shape => { +const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); - // 1. Внешний контур: Против часовой стрелки (CCW) - // (0,0) -> (len,0) -> (len,h) -> (0,h) -> (0,0) + // 1. Внешний контур: CCW (Против часовой) shape.moveTo(0, 0); shape.lineTo(length, 0); shape.lineTo(length, height); shape.lineTo(0, height); shape.lineTo(0, 0); - // Если перфорация выключена или стена слишком мала, возвращаем целую - if (!config.perforation?.enabled || length < 15 || height < 15) return shape; + // Если перфорация выключена или стена слишком мала + if (!config.perforation?.enabled || length < 10 || height < 10) return shape; const { pattern, diameter, spacing } = config.perforation; const step = diameter + Math.max(2, spacing); - const margin = 4; // Отступ от края стенки + const margin = 4; // Отступ от краев const effW = length - margin * 2; const effH = height - margin * 2; @@ -92,8 +99,6 @@ const createPerforatedWallShape = (length: number, height: number, config: AppCo const startX = margin + (effW - (cols - 1) * step) / 2; const startY = margin + (effH - (rows - 1) * rowH) / 2; - const holes: THREE.Path[] = []; - for (let j = 0; j < rows; j++) { const isOdd = j % 2 !== 0; const cy = startY + j * rowH; @@ -102,24 +107,20 @@ const createPerforatedWallShape = (length: number, height: number, config: AppCo let cx = startX + i * step; if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; - // Проверка выхода за границы + // Проверка границ if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; const hole = new THREE.Path(); const r = diameter / 2; - // 2. Отверстия: Строго по часовой стрелке (CW) - // Это критически важно для корректного отображения и экспорта! - + // 2. Отверстия: CW (По часовой) - Это критично для Three.js! if (pattern === 'circle') { - // aClockwise = true (CW) hole.absarc(cx, cy, r, 0, Math.PI * 2, true); } else if (pattern === 'hexagon') { - // Рисуем 6 точек по часовой стрелке for (let k = 0; k < 6; k++) { - // -k (отрицательный шаг) обеспечивает CW порядок + // -k обеспечивает CW порядок const angle = (-k * 60 + 90) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); @@ -129,7 +130,6 @@ const createPerforatedWallShape = (length: number, height: number, config: AppCo } else if (pattern === 'triangle') { const rot = isOdd ? 180 : 0; - // Рисуем 3 точки по часовой стрелке for (let k = 0; k < 3; k++) { const angle = (-k * 120 + 90 + rot) * Math.PI / 180; const px = cx + r * Math.cos(angle); @@ -138,21 +138,19 @@ const createPerforatedWallShape = (length: number, height: number, config: AppCo } hole.closePath(); } - holes.push(hole); + shape.holes.push(hole); } } - shape.holes = holes; return shape; }; -// Пол (всегда сплошной) +// Floor Shape (Solid) const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); const x = -width / 2; const y = -depth / 2; const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); - // CCW Order if (r <= 0.1) { shape.moveTo(x, y); shape.lineTo(x + width, y); @@ -173,18 +171,16 @@ const createFloorShape = (width: number, depth: number, radius: number): THREE.S return shape; }; -// Галтель (вогнутая) const createFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); shape.moveTo(0, 0); shape.lineTo(radius, 0); - // Дуга CW для выреза, но так как это тело вращения/экструзии, тут важна форма профиля shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); shape.lineTo(0, 0); return shape; }; -// --- СБОРКА МОДЕЛИ --- +// --- BUILDER --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig @@ -192,10 +188,10 @@ export const createBinGeometry = ( const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - // 1. ПОЛ + // 1. FLOOR const floorShape = createFloorShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // Лежит в плоскости XZ + floorGeo.rotateX(-Math.PI / 2); geometries.push(floorGeo); // Размеры внутреннего пространства @@ -203,129 +199,137 @@ export const createBinGeometry = ( const innerD = depth - 2 * thickness; const wallH = height - thickness; - // 2. ВНЕШНИЕ СТЕНКИ - // Мы создаем их вертикально. Базовая форма рисуется в XY (Width x Height), потом вращается. + // Helper для установки стенки + const placeWall = (length: number, isVertical: boolean, centerX: number, centerZ: number) => { + // Генерируем 2D форму с дырками + const shape = createPerforatedShape(length, wallH, safeConfig); + const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); + + if (isVertical) { + // Вертикальная (идет вдоль Z) + // Shape рисуется в XY. Extrude в Z. + // Поворачиваем вокруг Y на 90. + // X -> Z, Y -> Y, Z -> X. + // Теперь длина (бывший X) идет вдоль Z. Толщина (бывший Z) идет вдоль X. + geo.rotateY(Math.PI / 2); + + // Центр по X: centerX. Начало по Z: centerZ - length/2. + // После поворота: начало в (0,0,0) перешло в (0,0,0). + // Длина ушла в -Z (или +Z в зависимости от правил). + // Проще: ставим центр геометрии в центр позиции. + geo.center(); // Центрируем геометрию локально + geo.translate(centerX, thickness + wallH/2, centerZ); // Ставим на место + } else { + // Горизонтальная (идет вдоль X) + // Shape в XY. Extrude в Z. + // Длина вдоль X. Толщина вдоль Z. + geo.center(); + geo.translate(centerX, thickness + wallH/2, centerZ); + } + geometries.push(geo); + }; + + // 2. EXTERNAL WALLS + // Front (вдоль X) + placeWall(innerW, false, -width/2 + innerW/2 + thickness, depth/2 - thickness/2); // Исправленные координаты + // Проще: Front стоит на Z = depth/2 - thick/2. X центр = 0 (если floor от -W/2 до W/2). + // Floor shape: -W/2..W/2. - // -- Передняя и Задняя (Вдоль X) -- - const shapeFB = createPerforatedWallShape(innerW, wallH, safeConfig); + // Давайте пересчитаем позиции точно относительно центра (0,0) + // Front: CenterX=0, CenterZ = (depth - thickness)/2 + placeWall(innerW, false, 0, (depth - thickness)/2); - // Front - const geoF = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoF.translate(-innerW/2, thickness, depth/2 - thickness); // Центр X, на полу, край Z - geometries.push(geoF); - - // Back - const geoB = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoB.translate(-innerW/2, thickness, -depth/2); // Центр X, на полу, задний край Z - geometries.push(geoB); - - // -- Левая и Правая (Вдоль Z) -- - // Они идут по всей глубине (depth), перекрывая торцы передней/задней - const shapeLR = createPerforatedWallShape(depth, wallH, safeConfig); - - // Left - const geoL = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoL.rotateY(Math.PI / 2); // Поворот +90 (теперь идет вдоль Z) - // При повороте +90 вокруг (0,0,0): X+ -> Z-. Начало (0,0) остается (0,0). - // Нам нужно сместить начало в (X=-width/2, Z=-depth/2) - geoL.translate(-width/2, thickness, -depth/2); - geometries.push(geoL); - - // Right - const geoR = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoR.rotateY(Math.PI / 2); - geoR.translate(width/2 - thickness, thickness, -depth/2); - geometries.push(geoR); + // Back: CenterX=0, CenterZ = -(depth - thickness)/2 + placeWall(innerW, false, 0, -(depth - thickness)/2); + + // Left: CenterX=-(width - thickness)/2, CenterZ=0. Length = depth. + placeWall(depth, true, -(width - thickness)/2, 0); + + // Right: CenterX=(width - thickness)/2, CenterZ=0. Length = depth. + placeWall(depth, true, (width - thickness)/2, 0); - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - partitions.forEach(p => { + // 3. INTERNAL PARTITIONS + // Используем дедупликацию, чтобы убрать двойные стенки + const uniquePartitions = deduplicatePartitions(partitions); + + uniquePartitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - // Игнорируем некорректные if (pMax - pMin < 0.01) return; let length = 0; - let posX = 0; - let posZ = 0; - let isVert = false; + let cX = 0; + let cZ = 0; + let isVertical = false; if (p.axis === 'x') { - // Вертикальная на 2D-схеме (идет вдоль Z в 3D) - isVert = true; + // Вертикальная на экране 2D (вдоль Z в 3D) + isVertical = true; length = (pMax - pMin) * innerD; - // Центр по X - posX = (-innerW/2) + (p.offset * innerW); - // Начало по Z - posZ = (-innerD/2) + (pMin * innerD); + + // X: offset * innerW. Но innerW начинается от -innerW/2. + cX = (-innerW/2) + (p.offset * innerW); + + // Z центр: Середина между pMin и pMax + const midRatio = (pMin + pMax) / 2; + cZ = (-innerD/2) + (midRatio * innerD); } else { - // Горизонтальная на 2D-схеме (идет вдоль X в 3D) - isVert = false; + // Горизонтальная на экране 2D (вдоль X в 3D) + isVertical = false; length = (pMax - pMin) * innerW; - // Начало по X - posX = (-innerW/2) + (pMin * innerW); - // Центр по Z - posZ = (-innerD/2) + (p.offset * innerD); + + // X центр + const midRatio = (pMin + pMax) / 2; + cX = (-innerW/2) + (midRatio * innerW); + + // Z: offset * innerD + cZ = (-innerD/2) + (p.offset * innerD); } - // Генерируем форму с дырками - const partShape = createPerforatedWallShape(length, wallH, safeConfig); - const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); + placeWall(length, isVertical, cX, cZ); - if (isVert) { - // Поворачиваем вдоль Z - partGeo.rotateY(Math.PI / 2); - // Смещаем. Учитываем толщину, чтобы центрировать по линии реза. - partGeo.translate(posX - thickness/2, thickness, posZ); - } else { - // Вдоль X. Поворот не нужен. - // Смещаем. - partGeo.translate(posX, thickness, posZ - thickness/2); - } - - geometries.push(partGeo); - - // --- ГАЛТЕЛИ (FILLETS) --- + // --- FILLETS --- if (p.rounded && radius > 1) { - const fR = Math.min(radius, 5); + const fR = Math.min(radius, 5); const fShape = createFilletShape(fR); const h = p.height; - const addFillet = (fx: number, fz: number, rot: number) => { + const addFillet = (x: number, z: number, rot: number) => { const geo = new THREE.ExtrudeGeometry(fShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); // Кладем плашмя - geo.rotateY(rot); // Крутим - geo.translate(fx, thickness, fz); + geo.rotateX(-Math.PI / 2); + geo.rotateY(rot); + geo.translate(x, thickness, z); geometries.push(geo); }; const t = thickness / 2; - - if (isVert) { - const zStart = posZ; - const zEnd = posZ + length; - // Top junction - addFillet(posX - t, zStart, Math.PI); - addFillet(posX + t, zStart, -Math.PI/2); - // Bottom junction - addFillet(posX - t, zEnd, Math.PI/2); - addFillet(posX + t, zEnd, 0); + + // Вычисляем концы стенки для скруглений + if (isVertical) { + const zStart = cZ - length/2; + const zEnd = cZ + length/2; + + // Верхний стык (дальний по Z, если смотреть в 2D) -> Min + addFillet(cX - t, zStart, Math.PI); + addFillet(cX + t, zStart, -Math.PI/2); + // Нижний стык -> Max + addFillet(cX - t, zEnd, Math.PI/2); + addFillet(cX + t, zEnd, 0); } else { - const xStart = posX; - const xEnd = posX + length; - // Left junction - addFillet(xStart, posZ - t, 0); - addFillet(xStart, posZ + t, -Math.PI/2); - // Right junction - addFillet(xEnd, posZ - t, Math.PI/2); - addFillet(xEnd, posZ + t, Math.PI); + const xStart = cX - length/2; + const xEnd = cX + length/2; + + addFillet(xStart, cZ - t, 0); + addFillet(xStart, cZ + t, -Math.PI/2); + addFillet(xEnd, cZ - t, Math.PI/2); + addFillet(xEnd, cZ + t, Math.PI); } } }); const merged = mergeBufferGeometries(geometries); - // Пересчет нормалей критичен для правильного освещения (убирает "прозрачность") if (merged) { merged.computeVertexNormals(); return merged; From 0521c23246fb1f296e91999b68b67897eb37439a 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: Mon, 12 Jan 2026 01:19:20 +0300 Subject: [PATCH 07/21] 5 --- src/services/geometryGenerator.ts | 271 ++++++++++++++---------------- 1 file changed, 130 insertions(+), 141 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 77dc906..e4882b0 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,37 +2,23 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -// --- CLEANUP UTILS --- +// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// Удаляет дублирующиеся перегородки (фантомы) -const deduplicatePartitions = (partitions: Partition[]): Partition[] => { - const unique: Partition[] = []; - const seen = new Set(); - - partitions.forEach(p => { - // Округляем координаты для создания уникального ключа - const k = `${p.axis}-${p.offset.toFixed(3)}-${p.min?.toFixed(3)}-${p.max?.toFixed(3)}`; - if (!seen.has(k)) { - seen.add(k); - unique.push(p); - } - }); - return unique; +// Извлекаем ВСЕ перегородки из всех ячеек в один плоский список +const getAllPartitions = (splits: LayoutSplits): Partition[] => { + if (!splits || !splits.partitions) return []; + return Object.values(splits.partitions).flat(); }; -// Очистка точек для генерации цветных объемов -const cleanPoints = (points: number[]) => { - return Array.from(new Set(points.map(p => parseFloat(p.toFixed(3))))).sort((a, b) => a - b); -}; - -// --- CALCULATE VOLUMES (ЦВЕТНЫЕ КУБИКИ) --- export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; - - const uniqueX = cleanPoints([0, ...safeX, 1]); - const uniqueY = cleanPoints([0, ...safeY, 1]); + const safeParts = splits?.partitions || {}; + + // Просто сортируем точки, без сложной фильтрации, чтобы совпадало с 2D + const uniqueX = [0, ...safeX, 1].sort((a, b) => a - b); + const uniqueY = [0, ...safeY, 1].sort((a, b) => a - b); let partCounter = 1; @@ -43,24 +29,29 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; - const w = (x2 - x1) * config.drawer.width; - const d = (y2 - y1) * config.drawer.depth; + // Игнорируем вырожденные ячейки + if (x2 - x1 < 0.001 || y2 - y1 < 0.001) continue; - if (w < 2 || d < 2) continue; + const rawW = (x2 - x1) * config.drawer.width; + const rawD = (y2 - y1) * config.drawer.depth; + const rawX = x1 * config.drawer.width; + const rawY = y1 * config.drawer.depth; + + const internalPartitions = safeParts[`${i}-${j}`] || []; - // Отступ для визуализации (gap) - const gap = config.wallThickness / 2 + 0.2; + // Отступ для визуализации объемов (gap) + const gap = config.wallThickness / 2 + 0.1; parts.push({ id: `part-${partCounter}`, name: `Ячейка ${partCounter}`, - width: Math.max(1, w - gap * 2), - depth: Math.max(1, d - gap * 2), + width: Math.max(1, rawW - gap * 2), + depth: Math.max(1, rawD - gap * 2), height: config.drawer.height - config.wallThickness, - x: (x1 * config.drawer.width) + gap, - y: (y1 * config.drawer.depth) + gap, + x: rawX + gap, + y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, - internalPartitions: [] + internalPartitions: internalPartitions }); partCounter++; } @@ -68,24 +59,25 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- SHAPE GENERATION (PERFORATION) --- +// --- ГЕОМЕТРИЯ --- +// Прямоугольник с отверстиями const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); - // 1. Внешний контур: CCW (Против часовой) + // Внешний контур (CCW) shape.moveTo(0, 0); shape.lineTo(length, 0); shape.lineTo(length, height); shape.lineTo(0, height); shape.lineTo(0, 0); - // Если перфорация выключена или стена слишком мала + // Если перфорация выключена или стена мала if (!config.perforation?.enabled || length < 10 || height < 10) return shape; const { pattern, diameter, spacing } = config.perforation; const step = diameter + Math.max(2, spacing); - const margin = 4; // Отступ от краев + const margin = 3; const effW = length - margin * 2; const effH = height - margin * 2; @@ -107,28 +99,24 @@ const createPerforatedShape = (length: number, height: number, config: AppConfig let cx = startX + i * step; if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; - // Проверка границ if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; const hole = new THREE.Path(); const r = diameter / 2; - // 2. Отверстия: CW (По часовой) - Это критично для Three.js! + // ДЫРКИ СТРОГО ПО ЧАСОВОЙ (CW) if (pattern === 'circle') { hole.absarc(cx, cy, r, 0, Math.PI * 2, true); - } - else if (pattern === 'hexagon') { + } else if (pattern === 'hexagon') { for (let k = 0; k < 6; k++) { - // -k обеспечивает CW порядок const angle = (-k * 60 + 90) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); } hole.closePath(); - } - else if (pattern === 'triangle') { + } else if (pattern === 'triangle') { const rot = isOdd ? 180 : 0; for (let k = 0; k < 3; k++) { const angle = (-k * 120 + 90 + rot) * Math.PI / 180; @@ -144,9 +132,10 @@ const createPerforatedShape = (length: number, height: number, config: AppConfig return shape; }; -// Floor Shape (Solid) const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { const shape = new THREE.Shape(); + // Floor shape centered at 0,0 for ease of rotation later if needed, + // BUT createBinGeometry expects floor to be from -W/2 to W/2 const x = -width / 2; const y = -depth / 2; const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); @@ -171,6 +160,7 @@ const createFloorShape = (width: number, depth: number, radius: number): THREE.S return shape; }; +// Галтель (вогнутая) для стыков const createFilletShape = (radius: number): THREE.Shape => { const shape = new THREE.Shape(); shape.moveTo(0, 0); @@ -180,18 +170,30 @@ const createFilletShape = (radius: number): THREE.Shape => { return shape; }; -// --- BUILDER --- +// --- MAIN BUILDER --- +// Обратите внимание: сигнатура изменена, теперь мы принимаем splits целиком export const createBinGeometry = ( - width: number, depth: number, height: number, thickness: number, radius: number = 0, partitions: Partition[] = [], config?: AppConfig + width: number, depth: number, height: number, thickness: number, radius: number = 0, + splits: LayoutSplits | Partition[] = [], // Поддержка и старого, и нового формата + config?: AppConfig ): THREE.BufferGeometry => { + const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - // 1. FLOOR + // Нормализация входных данных: нам нужен плоский список стенок + let partitions: Partition[] = []; + if (Array.isArray(splits)) { + partitions = splits; + } else if (splits && splits.partitions) { + partitions = getAllPartitions(splits); + } + + // 1. ПОЛ const floorShape = createFloorShape(width, depth, radius); const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); + floorGeo.rotateX(-Math.PI / 2); // XZ plane geometries.push(floorGeo); // Размеры внутреннего пространства @@ -199,132 +201,119 @@ export const createBinGeometry = ( const innerD = depth - 2 * thickness; const wallH = height - thickness; - // Helper для установки стенки - const placeWall = (length: number, isVertical: boolean, centerX: number, centerZ: number) => { - // Генерируем 2D форму с дырками - const shape = createPerforatedShape(length, wallH, safeConfig); - const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); + // 2. ВНЕШНИЕ СТЕНКИ + const shapeFB = createPerforatedShape(innerW, wallH, safeConfig); + const shapeLR = createPerforatedShape(depth, wallH, safeConfig); - if (isVertical) { - // Вертикальная (идет вдоль Z) - // Shape рисуется в XY. Extrude в Z. - // Поворачиваем вокруг Y на 90. - // X -> Z, Y -> Y, Z -> X. - // Теперь длина (бывший X) идет вдоль Z. Толщина (бывший Z) идет вдоль X. - geo.rotateY(Math.PI / 2); - - // Центр по X: centerX. Начало по Z: centerZ - length/2. - // После поворота: начало в (0,0,0) перешло в (0,0,0). - // Длина ушла в -Z (или +Z в зависимости от правил). - // Проще: ставим центр геометрии в центр позиции. - geo.center(); // Центрируем геометрию локально - geo.translate(centerX, thickness + wallH/2, centerZ); // Ставим на место - } else { - // Горизонтальная (идет вдоль X) - // Shape в XY. Extrude в Z. - // Длина вдоль X. Толщина вдоль Z. - geo.center(); - geo.translate(centerX, thickness + wallH/2, centerZ); - } - geometries.push(geo); - }; + // Front (вдоль X, спереди) + const geoF = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); + geoF.translate(-innerW/2, thickness, depth/2 - thickness); + geometries.push(geoF); - // 2. EXTERNAL WALLS - // Front (вдоль X) - placeWall(innerW, false, -width/2 + innerW/2 + thickness, depth/2 - thickness/2); // Исправленные координаты - // Проще: Front стоит на Z = depth/2 - thick/2. X центр = 0 (если floor от -W/2 до W/2). - // Floor shape: -W/2..W/2. - - // Давайте пересчитаем позиции точно относительно центра (0,0) - // Front: CenterX=0, CenterZ = (depth - thickness)/2 - placeWall(innerW, false, 0, (depth - thickness)/2); - - // Back: CenterX=0, CenterZ = -(depth - thickness)/2 - placeWall(innerW, false, 0, -(depth - thickness)/2); - - // Left: CenterX=-(width - thickness)/2, CenterZ=0. Length = depth. - placeWall(depth, true, -(width - thickness)/2, 0); - - // Right: CenterX=(width - thickness)/2, CenterZ=0. Length = depth. - placeWall(depth, true, (width - thickness)/2, 0); + // Back (вдоль X, сзади) + const geoB = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); + geoB.translate(-innerW/2, thickness, -depth/2); + geometries.push(geoB); + // Left (вдоль Z, слева) + const geoL = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); + geoL.rotateY(Math.PI / 2); + geoL.translate(-width/2, thickness, -depth/2); + geometries.push(geoL); - // 3. INTERNAL PARTITIONS - // Используем дедупликацию, чтобы убрать двойные стенки - const uniquePartitions = deduplicatePartitions(partitions); + // Right (вдоль Z, справа) + const geoR = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); + geoR.rotateY(Math.PI / 2); + geoR.translate(width/2 - thickness, thickness, -depth/2); + geometries.push(geoR); - uniquePartitions.forEach(p => { + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (Исправлено позиционирование) + partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - if (pMax - pMin < 0.01) return; + // Игнорируем ошибки данных + if (pMax - pMin < 0.001) return; let length = 0; - let cX = 0; - let cZ = 0; - let isVertical = false; + let isVertical = false; // Vertical on 2D screen = Along Z axis in 3D + + // Вычисляем координаты центра и длины + let posX = 0; // Центр по X (для верт) или Начало по X (для гориз) + let posZ = 0; // Начало по Z (для верт) или Центр по Z (для гориз) if (p.axis === 'x') { - // Вертикальная на экране 2D (вдоль Z в 3D) + // Вертикальная на экране (Z-axis in 3D) isVertical = true; length = (pMax - pMin) * innerD; - - // X: offset * innerW. Но innerW начинается от -innerW/2. - cX = (-innerW/2) + (p.offset * innerW); - - // Z центр: Середина между pMin и pMax - const midRatio = (pMin + pMax) / 2; - cZ = (-innerD/2) + (midRatio * innerD); + // В 2D X идет слева направо (0..1). В 3D X идет от -innerW/2 до innerW/2. + posX = (-innerW/2) + (p.offset * innerW); + // В 2D Y идет сверху вниз (0..1). В 3D Z идет от -innerD/2 (зад) до innerD/2 (перед). + posZ = (-innerD/2) + (pMin * innerD); } else { - // Горизонтальная на экране 2D (вдоль X в 3D) + // Горизонтальная на экране (X-axis in 3D) isVertical = false; length = (pMax - pMin) * innerW; - - // X центр - const midRatio = (pMin + pMax) / 2; - cX = (-innerW/2) + (midRatio * innerW); - - // Z: offset * innerD - cZ = (-innerD/2) + (p.offset * innerD); + posX = (-innerW/2) + (pMin * innerW); + posZ = (-innerD/2) + (p.offset * innerD); } - placeWall(length, isVertical, cX, cZ); + // Генерируем 2D профиль + const partShape = createPerforatedShape(length, wallH, safeConfig); + const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); - // --- FILLETS --- + if (isVertical) { + // Поворот чтобы шла вдоль Z + partGeo.rotateY(Math.PI / 2); + // Смещаем в позицию. + // Центр X = posX. Но так как толщина экструзии идет в +X (после поворота), надо сместить на -thickness/2 + partGeo.translate(posX - thickness/2, thickness, posZ); + } else { + // Вдоль X + // Центр Z = posZ. Смещаем на -thickness/2 + partGeo.translate(posX, thickness, posZ - thickness/2); + } + + geometries.push(partGeo); + + // --- СКРУГЛЕНИЯ (FILLETS) --- if (p.rounded && radius > 1) { - const fR = Math.min(radius, 5); + const fR = Math.min(radius, 5); const fShape = createFilletShape(fR); const h = p.height; - const addFillet = (x: number, z: number, rot: number) => { + const addFillet = (fx: number, fz: number, rot: number) => { const geo = new THREE.ExtrudeGeometry(fShape, { depth: h, bevelEnabled: false }); geo.rotateX(-Math.PI / 2); geo.rotateY(rot); - geo.translate(x, thickness, z); + geo.translate(fx, thickness, fz); geometries.push(geo); }; const t = thickness / 2; - - // Вычисляем концы стенки для скруглений + if (isVertical) { - const zStart = cZ - length/2; - const zEnd = cZ + length/2; + const zStart = posZ; + const zEnd = posZ + length; - // Верхний стык (дальний по Z, если смотреть в 2D) -> Min - addFillet(cX - t, zStart, Math.PI); - addFillet(cX + t, zStart, -Math.PI/2); - // Нижний стык -> Max - addFillet(cX - t, zEnd, Math.PI/2); - addFillet(cX + t, zEnd, 0); + // Top junction (Z-min / Back) + addFillet(posX - t, zStart, Math.PI); // Face Back-Left + addFillet(posX + t, zStart, -Math.PI/2); // Face Back-Right + + // Bottom junction (Z-max / Front) + addFillet(posX - t, zEnd, Math.PI/2); // Face Front-Left + addFillet(posX + t, zEnd, 0); // Face Front-Right } else { - const xStart = cX - length/2; - const xEnd = cX + length/2; + const xStart = posX; + const xEnd = posX + length; - addFillet(xStart, cZ - t, 0); - addFillet(xStart, cZ + t, -Math.PI/2); - addFillet(xEnd, cZ - t, Math.PI/2); - addFillet(xEnd, cZ + t, Math.PI); + // Left junction (X-min / Left) + addFillet(xStart, posZ - t, 0); // Face Left-Back + addFillet(xStart, posZ + t, -Math.PI/2); // Face Left-Front + + // Right junction (X-max / Right) + addFillet(xEnd, posZ - t, Math.PI/2); // Face Right-Back + addFillet(xEnd, posZ + t, Math.PI); // Face Right-Front } } }); From e90348ab3235ec38a201bf6313627b02de481b65 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: Mon, 12 Jan 2026 01:35:02 +0300 Subject: [PATCH 08/21] Use three-bvh-csg --- package.json | 15 +- src/services/geometryGenerator.ts | 406 ++++++++++++------------------ 2 files changed, 162 insertions(+), 259 deletions(-) diff --git a/package.json b/package.json index 6290c6b..56fd87e 100644 --- a/package.json +++ b/package.json @@ -13,23 +13,24 @@ "start": "vite preview --port 3000 --host" }, "dependencies": { - "react": "^19.2.3", - "react-dom": "^19.2.3", - "three": "^0.182.0", - "three-stdlib": "^2.36.1", - "lucide-react": "^0.562.0", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.4.2", "jszip": "3.10.1", + "lucide-react": "^0.562.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "three": "^0.182.0", + "three-bvh-csg": "^0.0.17", + "three-stdlib": "^2.36.1", "uuid": "^9.0.1" }, "devDependencies": { + "@types/node": "^22.14.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@types/node": "^22.14.0", "@types/uuid": "^9.0.8", "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.8.2", "vite": "^6.2.0" } -} \ No newline at end of file +} diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index e4882b0..65881dc 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,10 +1,19 @@ import * as THREE from 'three'; -import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; +import { STLExporter } from 'three-stdlib'; +import { SUBTRACTION, UNION, Brush, Evaluator } from 'three-bvh-csg'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// Извлекаем ВСЕ перегородки из всех ячеек в один плоский список +// Функция для очистки дубликатов точек (убирает фантомные микро-ячейки) +const cleanPoints = (points: number[]) => { + // Округляем до 3 знака и сортируем + const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); + // Убираем дубликаты + return [...new Set(rounded)]; +}; + +// Получение списка всех перегородок const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; return Object.values(splits.partitions).flat(); @@ -14,11 +23,10 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; - const safeParts = splits?.partitions || {}; - - // Просто сортируем точки, без сложной фильтрации, чтобы совпадало с 2D - const uniqueX = [0, ...safeX, 1].sort((a, b) => a - b); - const uniqueY = [0, ...safeY, 1].sort((a, b) => a - b); + + // Очищаем координаты резов от мусора + const uniqueX = cleanPoints([0, ...safeX, 1]); + const uniqueY = cleanPoints([0, ...safeY, 1]); let partCounter = 1; @@ -29,17 +37,16 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; - // Игнорируем вырожденные ячейки - if (x2 - x1 < 0.001 || y2 - y1 < 0.001) continue; - const rawW = (x2 - x1) * config.drawer.width; const rawD = (y2 - y1) * config.drawer.depth; + + // Игнорируем слишком мелкие ячейки (защита от фантомов) + if (rawW < 2 || rawD < 2) continue; + const rawX = x1 * config.drawer.width; const rawY = y1 * config.drawer.depth; - const internalPartitions = safeParts[`${i}-${j}`] || []; - - // Отступ для визуализации объемов (gap) + // Отступ для визуализации (цветные кубики внутри ячеек) const gap = config.wallThickness / 2 + 0.1; parts.push({ @@ -47,11 +54,11 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat name: `Ячейка ${partCounter}`, width: Math.max(1, rawW - gap * 2), depth: Math.max(1, rawD - gap * 2), - height: config.drawer.height - config.wallThickness, + height: config.drawer.height, x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, - internalPartitions: internalPartitions + internalPartitions: [] }); partCounter++; } @@ -59,130 +66,50 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ --- +// --- CSG ГЕОМЕТРИЯ --- -// Прямоугольник с отверстиями -const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { - const shape = new THREE.Shape(); - - // Внешний контур (CCW) - shape.moveTo(0, 0); - shape.lineTo(length, 0); - shape.lineTo(length, height); - shape.lineTo(0, height); - shape.lineTo(0, 0); - - // Если перфорация выключена или стена мала - if (!config.perforation?.enabled || length < 10 || height < 10) return shape; - - const { pattern, diameter, spacing } = config.perforation; - const step = diameter + Math.max(2, spacing); - const margin = 3; - - const effW = length - margin * 2; - const effH = height - margin * 2; - - if (effW <= diameter || effH <= diameter) return shape; - - const rowH = pattern === 'circle' ? step : step * 0.866; - const cols = Math.floor(effW / step); - const rows = Math.floor(effH / rowH); - - const startX = margin + (effW - (cols - 1) * step) / 2; - const startY = margin + (effH - (rows - 1) * rowH) / 2; - - for (let j = 0; j < rows; j++) { - const isOdd = j % 2 !== 0; - const cy = startY + j * rowH; - - for (let i = 0; i < cols; i++) { - let cx = startX + i * step; - if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; - - if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || - cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; - - const hole = new THREE.Path(); - const r = diameter / 2; - - // ДЫРКИ СТРОГО ПО ЧАСОВОЙ (CW) - if (pattern === 'circle') { - hole.absarc(cx, cy, r, 0, Math.PI * 2, true); - } else if (pattern === 'hexagon') { - for (let k = 0; k < 6; k++) { - const angle = (-k * 60 + 90) * Math.PI / 180; - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); - } - hole.closePath(); - } else if (pattern === 'triangle') { - const rot = isOdd ? 180 : 0; - for (let k = 0; k < 3; k++) { - const angle = (-k * 120 + 90 + rot) * Math.PI / 180; - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); - } - hole.closePath(); - } - shape.holes.push(hole); - } - } - return shape; -}; - -const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - // Floor shape centered at 0,0 for ease of rotation later if needed, - // BUT createBinGeometry expects floor to be from -W/2 to W/2 - const x = -width / 2; - const y = -depth / 2; - const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); - - if (r <= 0.1) { - shape.moveTo(x, y); - shape.lineTo(x + width, y); - shape.lineTo(x + width, y + depth); - shape.lineTo(x, y + depth); - shape.lineTo(x, y); - } else { - shape.moveTo(x, y + r); - shape.lineTo(x, y + depth - r); - shape.quadraticCurveTo(x, y + depth, x + r, y + depth); - shape.lineTo(x + width - r, y + depth); - shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; -}; - -// Галтель (вогнутая) для стыков -const createFilletShape = (radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(radius, 0); - shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); - shape.lineTo(0, 0); - return shape; -}; - -// --- MAIN BUILDER --- - -// Обратите внимание: сигнатура изменена, теперь мы принимаем splits целиком export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, - splits: LayoutSplits | Partition[] = [], // Поддержка и старого, и нового формата + splits: LayoutSplits | Partition[] = [], config?: AppConfig ): THREE.BufferGeometry => { - const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; + const evaluator = new Evaluator(); - // Нормализация входных данных: нам нужен плоский список стенок + // 1. БАЗОВАЯ ГЕОМЕТРИЯ (ПОЛ) + // Brush - это специальный объект для CSG операций + const floorGeo = new THREE.BoxGeometry(width, thickness, depth); + floorGeo.translate(0, thickness / 2, 0); // Поднимаем на уровень пола + let resultBrush = new Brush(floorGeo); + + // Материал для CSG (нужен для вычислений, но не влияет на экспорт) + resultBrush.updateMatrixWorld(); + + // 2. СТЕНКИ (ВНЕШНИЕ) + const wallH = height - thickness; + const innerW = width - 2 * thickness; + const innerD = depth - 2 * thickness; + + // Функция создания блока стены + const addWallBlock = (w: number, h: number, d: number, x: number, y: number, z: number) => { + const geo = new THREE.BoxGeometry(w, h, d); + geo.translate(x, y, z); + const wallBrush = new Brush(geo); + wallBrush.updateMatrixWorld(); + // Объединяем (UNION) стену с полом + resultBrush = evaluator.evaluate(resultBrush, wallBrush, UNION); + }; + + // Передняя и Задняя (Вдоль X) + addWallBlock(innerW, wallH, thickness, 0, thickness + wallH/2, depth/2 - thickness/2); // Front + addWallBlock(innerW, wallH, thickness, 0, thickness + wallH/2, -depth/2 + thickness/2); // Back + + // Левая и Правая (Вдоль Z) - Полная глубина, перекрывают углы + addWallBlock(thickness, wallH, depth, -width/2 + thickness/2, thickness + wallH/2, 0); // Left + addWallBlock(thickness, wallH, depth, width/2 - thickness/2, thickness + wallH/2, 0); // Right + + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ let partitions: Partition[] = []; if (Array.isArray(splits)) { partitions = splits; @@ -190,145 +117,120 @@ export const createBinGeometry = ( partitions = getAllPartitions(splits); } - // 1. ПОЛ - const floorShape = createFloorShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // XZ plane - geometries.push(floorGeo); - - // Размеры внутреннего пространства - const innerW = width - 2 * thickness; - const innerD = depth - 2 * thickness; - const wallH = height - thickness; - - // 2. ВНЕШНИЕ СТЕНКИ - const shapeFB = createPerforatedShape(innerW, wallH, safeConfig); - const shapeLR = createPerforatedShape(depth, wallH, safeConfig); - - // Front (вдоль X, спереди) - const geoF = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoF.translate(-innerW/2, thickness, depth/2 - thickness); - geometries.push(geoF); - - // Back (вдоль X, сзади) - const geoB = new THREE.ExtrudeGeometry(shapeFB, { depth: thickness, bevelEnabled: false }); - geoB.translate(-innerW/2, thickness, -depth/2); - geometries.push(geoB); - - // Left (вдоль Z, слева) - const geoL = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoL.rotateY(Math.PI / 2); - geoL.translate(-width/2, thickness, -depth/2); - geometries.push(geoL); - - // Right (вдоль Z, справа) - const geoR = new THREE.ExtrudeGeometry(shapeLR, { depth: thickness, bevelEnabled: false }); - geoR.rotateY(Math.PI / 2); - geoR.translate(width/2 - thickness, thickness, -depth/2); - geometries.push(geoR); - - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (Исправлено позиционирование) partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - // Игнорируем ошибки данных if (pMax - pMin < 0.001) return; - let length = 0; - let isVertical = false; // Vertical on 2D screen = Along Z axis in 3D - - // Вычисляем координаты центра и длины - let posX = 0; // Центр по X (для верт) или Начало по X (для гориз) - let posZ = 0; // Начало по Z (для верт) или Центр по Z (для гориз) + let w=0, h=p.height, d=0, x=0, z=0; if (p.axis === 'x') { - // Вертикальная на экране (Z-axis in 3D) - isVertical = true; - length = (pMax - pMin) * innerD; - // В 2D X идет слева направо (0..1). В 3D X идет от -innerW/2 до innerW/2. - posX = (-innerW/2) + (p.offset * innerW); - // В 2D Y идет сверху вниз (0..1). В 3D Z идет от -innerD/2 (зад) до innerD/2 (перед). - posZ = (-innerD/2) + (pMin * innerD); + // Вертикальная на 2D (Вдоль Z в 3D) + w = thickness; + d = (pMax - pMin) * innerD; + x = (-innerW/2) + (p.offset * innerW); + z = (-innerD/2) + (pMin * innerD) + (d / 2); } else { - // Горизонтальная на экране (X-axis in 3D) - isVertical = false; - length = (pMax - pMin) * innerW; - posX = (-innerW/2) + (pMin * innerW); - posZ = (-innerD/2) + (p.offset * innerD); + // Горизонтальная на 2D (Вдоль X в 3D) + w = (pMax - pMin) * innerW; + d = thickness; + x = (-innerW/2) + (pMin * innerW) + (w / 2); + z = (-innerD/2) + (p.offset * innerD); } - // Генерируем 2D профиль - const partShape = createPerforatedShape(length, wallH, safeConfig); - const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); - - if (isVertical) { - // Поворот чтобы шла вдоль Z - partGeo.rotateY(Math.PI / 2); - // Смещаем в позицию. - // Центр X = posX. Но так как толщина экструзии идет в +X (после поворота), надо сместить на -thickness/2 - partGeo.translate(posX - thickness/2, thickness, posZ); - } else { - // Вдоль X - // Центр Z = posZ. Смещаем на -thickness/2 - partGeo.translate(posX, thickness, posZ - thickness/2); - } - - geometries.push(partGeo); - - // --- СКРУГЛЕНИЯ (FILLETS) --- - if (p.rounded && radius > 1) { - const fR = Math.min(radius, 5); - const fShape = createFilletShape(fR); - const h = p.height; - - const addFillet = (fx: number, fz: number, rot: number) => { - const geo = new THREE.ExtrudeGeometry(fShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); - geo.rotateY(rot); - geo.translate(fx, thickness, fz); - geometries.push(geo); - }; - - const t = thickness / 2; - - if (isVertical) { - const zStart = posZ; - const zEnd = posZ + length; - - // Top junction (Z-min / Back) - addFillet(posX - t, zStart, Math.PI); // Face Back-Left - addFillet(posX + t, zStart, -Math.PI/2); // Face Back-Right - - // Bottom junction (Z-max / Front) - addFillet(posX - t, zEnd, Math.PI/2); // Face Front-Left - addFillet(posX + t, zEnd, 0); // Face Front-Right - } else { - const xStart = posX; - const xEnd = posX + length; - - // Left junction (X-min / Left) - addFillet(xStart, posZ - t, 0); // Face Left-Back - addFillet(xStart, posZ + t, -Math.PI/2); // Face Left-Front - - // Right junction (X-max / Right) - addFillet(xEnd, posZ - t, Math.PI/2); // Face Right-Back - addFillet(xEnd, posZ + t, Math.PI); // Face Right-Front - } - } + addWallBlock(w, h, d, x, thickness + h/2, z); }); - const merged = mergeBufferGeometries(geometries); - if (merged) { - merged.computeVertexNormals(); - return merged; + // 4. ПЕРФОРАЦИЯ (ВЫЧИТАНИЕ) + // Чтобы не тормозить, мы создаем ОДИН сложный объект из всех "сверл" и вычитаем его один раз + if (safeConfig.perforation?.enabled) { + const { pattern, diameter, spacing } = safeConfig.perforation; + const holeGeo = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 16); + + // Поворачиваем цилиндр, чтобы он "сверлил" вдоль оси X (для боковых стенок) + holeGeo.rotateZ(Math.PI / 2); + + const step = diameter + Math.max(2, spacing); + const margin = 4; + + // Массив геометрий для слияния (это быстрее, чем 1000 раз вызывать CSG) + const cutters: THREE.BufferGeometry[] = []; + + // Функция генерации "сверл" для плоскости + const generateCutters = (W: number, H: number, startX: number, startY: number, startZ: number, rotateY: boolean) => { + const cols = Math.floor((W - margin*2) / step); + const rowH = pattern === 'circle' ? step : step * 0.866; + const rows = Math.floor((H - margin*2) / rowH); + + const offsetX = (W - cols * step) / 2; + const offsetY = (H - rows * rowH) / 2; + + for(let j=0; j W - margin || hy > H - margin) continue; + + const cutter = holeGeo.clone(); + + if (rotateY) { + // Для стенок, идущих вдоль X (Передняя/Задняя) + // Изначально цилиндр вдоль X. Поворачиваем на 90 -> Вдоль Z. + cutter.rotateY(Math.PI / 2); + // Позиционируем + // В локальной системе стенки: X=Длина, Y=Высота. + // Глобально: X=startX+hx, Y=startY+hy, Z=startZ + cutter.translate(startX + hx, startY + hy, startZ); + } else { + // Для стенок, идущих вдоль Z (Левая/Правая) + // Цилиндр вдоль X (по умолчанию). + // Глобально: X=startX, Y=startY+hy, Z=startZ+hx + cutter.translate(startX, startY + hy, startZ + hx); + } + cutters.push(cutter); + } + } + }; + + // Генерируем сверла для всех 4 сторон + // Front/Back (Сверлим вдоль Z) + generateCutters(innerW, wallH, -innerW/2, thickness, depth/2, true); // Front plane + generateCutters(innerW, wallH, -innerW/2, thickness, -depth/2, true); // Back plane + + // Left/Right (Сверлим вдоль X) + // Для боковых стенок (Left/Right) W = depth. + generateCutters(depth, wallH, -width/2, thickness, -depth/2, false); // Left plane + generateCutters(depth, wallH, width/2, thickness, -depth/2, false); // Right plane + + // Если есть внутренние стенки, их тоже надо бы сверлить, но это сложнее рассчитать. + // Пока сверлим только внешний периметр, как в Gridfinity. + // (Можно добавить логику для внутренних, перебирая partitions, если нужно) + + if (cutters.length > 0) { + // Объединяем все сверла в один Mesh + // Используем mergeBufferGeometries из three-stdlib, так как в чистом three его вынесли + const mergedCutters = mergeBufferGeometries(cutters); + if (mergedCutters) { + const cutterBrush = new Brush(mergedCutters); + cutterBrush.updateMatrixWorld(); + // ВЫЧИТАНИЕ (SUBTRACTION) + resultBrush = evaluator.evaluate(resultBrush, cutterBrush, SUBTRACTION); + } + } } - - return new THREE.BoxGeometry(1, 1, 1); + + // Возвращаем чистую геометрию + return resultBrush.geometry; }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); + // Для CSG геометрии иногда нужно убедиться, что она корректно интерпретируется const result = exporter.parse(mesh, { binary: true }); if (result instanceof DataView) return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); return result as string; From 7af156fea08bb660f132ff306938e53d19af29d1 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: Mon, 12 Jan 2026 01:44:19 +0300 Subject: [PATCH 09/21] Try fix used three-bvh-csg --- package-lock.json | 2675 +++++++++++++++++++++++++++++ src/services/geometryGenerator.ts | 93 +- 2 files changed, 2704 insertions(+), 64 deletions(-) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a601a0e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2675 @@ +{ + "name": "printfit-organizer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "printfit-organizer", + "version": "1.0.0", + "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.4.2", + "jszip": "3.10.1", + "lucide-react": "^0.562.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "three": "^0.182.0", + "three-bvh-csg": "^0.0.17", + "three-stdlib": "^2.36.1", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/uuid": "^9.0.8", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/drei/node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", + "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", + "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", + "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.68.tgz", + "integrity": "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==", + "license": "BSD-3-Clause" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", + "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", + "license": "MIT" + }, + "node_modules/three-bvh-csg": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/three-bvh-csg/-/three-bvh-csg-0.0.17.tgz", + "integrity": "sha512-iEkHDF8GRfGM6593Cuw8SnF1vfENCp46gIAtRzuL4nGXGWPcR1sbTBwM9ptDONb5twqlZp5WkAKya5aBKe2qcA==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.151.0", + "three-mesh-bvh": ">=0.6.6" + } + }, + "node_modules/three-mesh-bvh": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.5.tgz", + "integrity": "sha512-MYpwzUWDxPAKGhSBFin9E/7K4AAHyIm4IfMZQ/3+Z/jq/swa2dAhXx0yUNDd9mjlhLuzXkMBTGDZioL2GSlIfQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 65881dc..b611b54 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,30 +1,26 @@ import * as THREE from 'three'; import { STLExporter } from 'three-stdlib'; -import { SUBTRACTION, UNION, Brush, Evaluator } from 'three-bvh-csg'; +import { SUBTRACTION, ADDITION, Brush, Evaluator } from 'three-bvh-csg'; // ИСПРАВЛЕНО: ADDITION вместо UNION import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// Функция для очистки дубликатов точек (убирает фантомные микро-ячейки) const cleanPoints = (points: number[]) => { - // Округляем до 3 знака и сортируем const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); - // Убираем дубликаты return [...new Set(rounded)]; }; -// Получение списка всех перегородок const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; return Object.values(splits.partitions).flat(); }; +// Функция для визуализации "кубиков" ячеек (цветной предпросмотр) export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; - // Очищаем координаты резов от мусора const uniqueX = cleanPoints([0, ...safeX, 1]); const uniqueY = cleanPoints([0, ...safeY, 1]); @@ -37,16 +33,13 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; + if (x2 - x1 < 0.001 || y2 - y1 < 0.001) continue; + const rawW = (x2 - x1) * config.drawer.width; const rawD = (y2 - y1) * config.drawer.depth; - - // Игнорируем слишком мелкие ячейки (защита от фантомов) - if (rawW < 2 || rawD < 2) continue; - const rawX = x1 * config.drawer.width; const rawY = y1 * config.drawer.depth; - // Отступ для визуализации (цветные кубики внутри ячеек) const gap = config.wallThickness / 2 + 0.1; parts.push({ @@ -66,7 +59,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- CSG ГЕОМЕТРИЯ --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ ЧЕРЕЗ CSG (ВЫЧИТАНИЕ) --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, @@ -77,39 +70,33 @@ export const createBinGeometry = ( const safeConfig = config || { perforation: { enabled: false } } as AppConfig; const evaluator = new Evaluator(); - // 1. БАЗОВАЯ ГЕОМЕТРИЯ (ПОЛ) - // Brush - это специальный объект для CSG операций + // 1. БАЗА (ПОЛ) const floorGeo = new THREE.BoxGeometry(width, thickness, depth); - floorGeo.translate(0, thickness / 2, 0); // Поднимаем на уровень пола + floorGeo.translate(0, thickness / 2, 0); let resultBrush = new Brush(floorGeo); - - // Материал для CSG (нужен для вычислений, но не влияет на экспорт) resultBrush.updateMatrixWorld(); - // 2. СТЕНКИ (ВНЕШНИЕ) + // 2. СТЕНКИ const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // Функция создания блока стены + // Функция добавления блока стены (ADDITION) const addWallBlock = (w: number, h: number, d: number, x: number, y: number, z: number) => { const geo = new THREE.BoxGeometry(w, h, d); geo.translate(x, y, z); const wallBrush = new Brush(geo); wallBrush.updateMatrixWorld(); - // Объединяем (UNION) стену с полом - resultBrush = evaluator.evaluate(resultBrush, wallBrush, UNION); + resultBrush = evaluator.evaluate(resultBrush, wallBrush, ADDITION); // ИСПРАВЛЕНО ЗДЕСЬ }; - // Передняя и Задняя (Вдоль X) + // Внешние стенки addWallBlock(innerW, wallH, thickness, 0, thickness + wallH/2, depth/2 - thickness/2); // Front addWallBlock(innerW, wallH, thickness, 0, thickness + wallH/2, -depth/2 + thickness/2); // Back - - // Левая и Правая (Вдоль Z) - Полная глубина, перекрывают углы addWallBlock(thickness, wallH, depth, -width/2 + thickness/2, thickness + wallH/2, 0); // Left addWallBlock(thickness, wallH, depth, width/2 - thickness/2, thickness + wallH/2, 0); // Right - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ + // Внутренние перегородки let partitions: Partition[] = []; if (Array.isArray(splits)) { partitions = splits; @@ -120,44 +107,37 @@ export const createBinGeometry = ( partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - if (pMax - pMin < 0.001) return; let w=0, h=p.height, d=0, x=0, z=0; - if (p.axis === 'x') { - // Вертикальная на 2D (Вдоль Z в 3D) + if (p.axis === 'x') { // Вертикальная (вдоль Z) w = thickness; d = (pMax - pMin) * innerD; x = (-innerW/2) + (p.offset * innerW); z = (-innerD/2) + (pMin * innerD) + (d / 2); - } else { - // Горизонтальная на 2D (Вдоль X в 3D) + } else { // Горизонтальная (вдоль X) w = (pMax - pMin) * innerW; d = thickness; x = (-innerW/2) + (pMin * innerW) + (w / 2); z = (-innerD/2) + (p.offset * innerD); } - addWallBlock(w, h, d, x, thickness + h/2, z); }); - // 4. ПЕРФОРАЦИЯ (ВЫЧИТАНИЕ) - // Чтобы не тормозить, мы создаем ОДИН сложный объект из всех "сверл" и вычитаем его один раз + // 3. ПЕРФОРАЦИЯ (ВЫЧИТАНИЕ) if (safeConfig.perforation?.enabled) { const { pattern, diameter, spacing } = safeConfig.perforation; + // Цилиндр для вырезания (длинный, чтобы прошел насквозь) const holeGeo = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 16); - - // Поворачиваем цилиндр, чтобы он "сверлил" вдоль оси X (для боковых стенок) - holeGeo.rotateZ(Math.PI / 2); + holeGeo.rotateZ(Math.PI / 2); // По умолчанию вдоль X (для боковых стен) const step = diameter + Math.max(2, spacing); const margin = 4; - // Массив геометрий для слияния (это быстрее, чем 1000 раз вызывать CSG) + // Собираем все "сверла" в одну геометрию для скорости const cutters: THREE.BufferGeometry[] = []; - // Функция генерации "сверл" для плоскости const generateCutters = (W: number, H: number, startX: number, startY: number, startZ: number, rotateY: boolean) => { const cols = Math.floor((W - margin*2) / step); const rowH = pattern === 'circle' ? step : step * 0.866; @@ -179,17 +159,11 @@ export const createBinGeometry = ( const cutter = holeGeo.clone(); if (rotateY) { - // Для стенок, идущих вдоль X (Передняя/Задняя) - // Изначально цилиндр вдоль X. Поворачиваем на 90 -> Вдоль Z. + // Для передней/задней стенки (сверлим вдоль Z) cutter.rotateY(Math.PI / 2); - // Позиционируем - // В локальной системе стенки: X=Длина, Y=Высота. - // Глобально: X=startX+hx, Y=startY+hy, Z=startZ cutter.translate(startX + hx, startY + hy, startZ); } else { - // Для стенок, идущих вдоль Z (Левая/Правая) - // Цилиндр вдоль X (по умолчанию). - // Глобально: X=startX, Y=startY+hy, Z=startZ+hx + // Для боковых стенок (сверлим вдоль X) cutter.translate(startX, startY + hy, startZ + hx); } cutters.push(cutter); @@ -197,40 +171,31 @@ export const createBinGeometry = ( } }; - // Генерируем сверла для всех 4 сторон - // Front/Back (Сверлим вдоль Z) - generateCutters(innerW, wallH, -innerW/2, thickness, depth/2, true); // Front plane - generateCutters(innerW, wallH, -innerW/2, thickness, -depth/2, true); // Back plane - - // Left/Right (Сверлим вдоль X) - // Для боковых стенок (Left/Right) W = depth. - generateCutters(depth, wallH, -width/2, thickness, -depth/2, false); // Left plane - generateCutters(depth, wallH, width/2, thickness, -depth/2, false); // Right plane - - // Если есть внутренние стенки, их тоже надо бы сверлить, но это сложнее рассчитать. - // Пока сверлим только внешний периметр, как в Gridfinity. - // (Можно добавить логику для внутренних, перебирая partitions, если нужно) + // Генерируем отверстия для внешних стен + generateCutters(innerW, wallH, -innerW/2, thickness, depth/2, true); // Front + generateCutters(innerW, wallH, -innerW/2, thickness, -depth/2, true); // Back + generateCutters(depth, wallH, -width/2, thickness, -depth/2, false); // Left + generateCutters(depth, wallH, width/2, thickness, -depth/2, false); // Right + // Применяем вычитание (SUBTRACTION) if (cutters.length > 0) { - // Объединяем все сверла в один Mesh - // Используем mergeBufferGeometries из three-stdlib, так как в чистом three его вынесли + // Используем функцию слияния из three-stdlib (так как в core three её может не быть в старых версиях) + // Если mergeBufferGeometries не импортирован, убедитесь что он есть в импортах const mergedCutters = mergeBufferGeometries(cutters); if (mergedCutters) { const cutterBrush = new Brush(mergedCutters); cutterBrush.updateMatrixWorld(); - // ВЫЧИТАНИЕ (SUBTRACTION) resultBrush = evaluator.evaluate(resultBrush, cutterBrush, SUBTRACTION); } } } - // Возвращаем чистую геометрию + // Возвращаем результат return resultBrush.geometry; }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); - // Для CSG геометрии иногда нужно убедиться, что она корректно интерпретируется const result = exporter.parse(mesh, { binary: true }); if (result instanceof DataView) return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); return result as string; From fa2f02bd183de1bd7fbb320c445a1d15726cca70 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: Mon, 12 Jan 2026 01:57:09 +0300 Subject: [PATCH 10/21] 1 --- src/services/geometryGenerator.ts | 358 ++++++++++++++++++++---------- 1 file changed, 245 insertions(+), 113 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index b611b54..46db0fe 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,28 +1,24 @@ import * as THREE from 'three'; -import { STLExporter } from 'three-stdlib'; -import { SUBTRACTION, ADDITION, Brush, Evaluator } from 'three-bvh-csg'; // ИСПРАВЛЕНО: ADDITION вместо UNION +import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -const cleanPoints = (points: number[]) => { - const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); - return [...new Set(rounded)]; -}; - +// 1. Собираем все перегородки из всех ячеек в один плоский список const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; + // Проходимся по всем ключам ("0-0", "0-1" и т.д.) и собираем массивы в один return Object.values(splits.partitions).flat(); }; -// Функция для визуализации "кубиков" ячеек (цветной предпросмотр) export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; - const uniqueX = cleanPoints([0, ...safeX, 1]); - const uniqueY = cleanPoints([0, ...safeY, 1]); + // Уникальные точки реза для визуализации "цветных кубиков" + const uniqueX = [0, ...safeX, 1].sort((a, b) => a - b); + const uniqueY = [0, ...safeY, 1].sort((a, b) => a - b); let partCounter = 1; @@ -33,6 +29,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; + // Фильтр микро-ячеек if (x2 - x1 < 0.001 || y2 - y1 < 0.001) continue; const rawW = (x2 - x1) * config.drawer.width; @@ -47,7 +44,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat name: `Ячейка ${partCounter}`, width: Math.max(1, rawW - gap * 2), depth: Math.max(1, rawD - gap * 2), - height: config.drawer.height, + height: config.drawer.height - config.wallThickness, // Учитываем пол x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, @@ -59,44 +56,175 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ ЧЕРЕЗ CSG (ВЫЧИТАНИЕ) --- +// --- ГЕОМЕТРИЯ --- + +// Создание формы стены с отверстиями (Правильный Winding Order!) +const createWallShapeWithHoles = (length: number, height: number, config: AppConfig): THREE.Shape => { + const shape = new THREE.Shape(); + + // 1. Контур стены (CCW - Против часовой) + shape.moveTo(0, 0); + shape.lineTo(length, 0); + shape.lineTo(length, height); + shape.lineTo(0, height); + shape.lineTo(0, 0); + + // Если перфорация выключена или стена слишком мала + if (!config.perforation?.enabled || length < 15 || height < 15) return shape; + + const { pattern, diameter, spacing } = config.perforation; + const step = diameter + Math.max(2, spacing); + const margin = 4; + + // Рабочая область + const effW = length - margin * 2; + const effH = height - margin * 2; + + if (effW <= diameter || effH <= diameter) return shape; + + // Расчет сетки + const rowH = pattern === 'circle' ? step : step * 0.866; + const cols = Math.floor(effW / step); + const rows = Math.floor(effH / rowH); + + const startX = margin + (effW - (cols - 1) * step) / 2; + const startY = margin + (effH - (rows - 1) * rowH) / 2; + + for (let j = 0; j < rows; j++) { + const isOdd = j % 2 !== 0; + const cy = startY + j * rowH; + + for (let i = 0; i < cols; i++) { + let cx = startX + i * step; + if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; + + // Проверка границ + if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || + cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; + + const hole = new THREE.Path(); + const r = diameter / 2; + + // 2. Отверстия (CW - По часовой стрелке) + // Это критически важно для Three.js, иначе дырки не вырежутся + + if (pattern === 'circle') { + hole.absarc(cx, cy, r, 0, Math.PI * 2, true); + } + else if (pattern === 'hexagon') { + for (let k = 0; k < 6; k++) { + const angle = (-k * 60 + 90) * Math.PI / 180; // Минус k = CW + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); + } + hole.closePath(); + } + else if (pattern === 'triangle') { + const rot = isOdd ? 180 : 0; + for (let k = 0; k < 3; k++) { + const angle = (-k * 120 + 90 + rot) * Math.PI / 180; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); + } + hole.closePath(); + } + shape.holes.push(hole); + } + } + return shape; +}; + +// Форма пола (Сплошная) +const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { + const shape = new THREE.Shape(); + const x = -width / 2; + const y = -depth / 2; + const r = Math.min(radius, width / 2, depth / 2); + + if (r <= 0.1) { + shape.moveTo(x, y); + shape.lineTo(x + width, y); + shape.lineTo(x + width, y + depth); + shape.lineTo(x, y + depth); + shape.lineTo(x, y); + } else { + shape.moveTo(x, y + r); + shape.lineTo(x, y + depth - r); + shape.quadraticCurveTo(x, y + depth, x + r, y + depth); + shape.lineTo(x + width - r, y + depth); + shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; +}; + +// Форма скругления (Concave fillet) +const createFilletShape = (radius: number): THREE.Shape => { + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + shape.lineTo(radius, 0); + shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); + shape.lineTo(0, 0); + return shape; +}; + +// --- СБОРКА МОДЕЛИ --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, - splits: LayoutSplits | Partition[] = [], + splits: LayoutSplits | Partition[] = [], // Принимаем весь объект splits config?: AppConfig ): THREE.BufferGeometry => { + const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - const evaluator = new Evaluator(); - // 1. БАЗА (ПОЛ) - const floorGeo = new THREE.BoxGeometry(width, thickness, depth); - floorGeo.translate(0, thickness / 2, 0); - let resultBrush = new Brush(floorGeo); - resultBrush.updateMatrixWorld(); + // 1. ПОЛ + const floorShape = createFloorShape(width, depth, radius); + const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); + floorGeo.rotateX(-Math.PI / 2); // Кладем на пол + geometries.push(floorGeo); - // 2. СТЕНКИ - const wallH = height - thickness; + // Внутренние размеры (без учета толщины внешних стен) const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; + const wallH = height - thickness; - // Функция добавления блока стены (ADDITION) - const addWallBlock = (w: number, h: number, d: number, x: number, y: number, z: number) => { - const geo = new THREE.BoxGeometry(w, h, d); - geo.translate(x, y, z); - const wallBrush = new Brush(geo); - wallBrush.updateMatrixWorld(); - resultBrush = evaluator.evaluate(resultBrush, wallBrush, ADDITION); // ИСПРАВЛЕНО ЗДЕСЬ - }; + // 2. ВНЕШНИЕ СТЕНКИ + // Создаем 2D профили с дырками + const shapeFrontBack = createWallShapeWithHoles(innerW, wallH, safeConfig); + const shapeLeftRight = createWallShapeWithHoles(depth, wallH, safeConfig); // Боковые на всю глубину - // Внешние стенки - addWallBlock(innerW, wallH, thickness, 0, thickness + wallH/2, depth/2 - thickness/2); // Front - addWallBlock(innerW, wallH, thickness, 0, thickness + wallH/2, -depth/2 + thickness/2); // Back - addWallBlock(thickness, wallH, depth, -width/2 + thickness/2, thickness + wallH/2, 0); // Left - addWallBlock(thickness, wallH, depth, width/2 - thickness/2, thickness + wallH/2, 0); // Right + // Front (Спереди) + const geoF = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); + geoF.translate(-innerW/2, thickness, depth/2 - thickness); + geometries.push(geoF); - // Внутренние перегородки + // Back (Сзади) + const geoB = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); + geoB.translate(-innerW/2, thickness, -depth/2); + geometries.push(geoB); + + // Left (Слева) + const geoL = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); + geoL.rotateY(Math.PI / 2); + geoL.translate(-width/2, thickness, -depth/2); + geometries.push(geoL); + + // Right (Справа) + const geoR = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); + geoR.rotateY(Math.PI / 2); + geoR.translate(width/2 - thickness, thickness, -depth/2); + geometries.push(geoR); + + + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ + // Важно: извлекаем плоский массив стенок let partitions: Partition[] = []; if (Array.isArray(splits)) { partitions = splits; @@ -107,91 +235,95 @@ export const createBinGeometry = ( partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; + + // Игнорируем ошибки данных if (pMax - pMin < 0.001) return; - let w=0, h=p.height, d=0, x=0, z=0; + let length = 0; + let posX = 0; + let posZ = 0; + let isVertical = false; - if (p.axis === 'x') { // Вертикальная (вдоль Z) - w = thickness; - d = (pMax - pMin) * innerD; - x = (-innerW/2) + (p.offset * innerW); - z = (-innerD/2) + (pMin * innerD) + (d / 2); - } else { // Горизонтальная (вдоль X) - w = (pMax - pMin) * innerW; - d = thickness; - x = (-innerW/2) + (pMin * innerW) + (w / 2); - z = (-innerD/2) + (p.offset * innerD); + // Рассчитываем координаты и размеры + if (p.axis === 'x') { + // Вертикальная на экране (Вдоль Z) + isVertical = true; + length = (pMax - pMin) * innerD; + // X: центр линии + posX = (-innerW/2) + (p.offset * innerW); + // Z: начало линии + posZ = (-innerD/2) + (pMin * innerD); + } else { + // Горизонтальная на экране (Вдоль X) + isVertical = false; + length = (pMax - pMin) * innerW; + // X: начало линии + posX = (-innerW/2) + (pMin * innerW); + // Z: центр линии + posZ = (-innerD/2) + (p.offset * innerD); + } + + // Создаем стенку с дырками + const partShape = createWallShapeWithHoles(length, wallH, safeConfig); + const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); + + if (isVertical) { + // Поворачиваем вдоль Z + partGeo.rotateY(Math.PI / 2); + // Смещаем: X - половина толщины (для центровки), Y=thick, Z=начало + partGeo.translate(posX - thickness/2, thickness, posZ); + } else { + // Вдоль X + // Смещаем: X=начало, Y=thick, Z - половина толщины + partGeo.translate(posX, thickness, posZ - thickness/2); + } + + geometries.push(partGeo); + + // --- СКРУГЛЕНИЯ (FILLETS) --- + if (p.rounded && radius > 1) { + const fR = Math.min(radius, 5); + const fShape = createFilletShape(fR); + const h = p.height; + + const addFillet = (fx: number, fz: number, rot: number) => { + const geo = new THREE.ExtrudeGeometry(fShape, { depth: h, bevelEnabled: false }); + geo.rotateX(-Math.PI / 2); + geo.rotateY(rot); + geo.translate(fx, thickness, fz); + geometries.push(geo); + }; + + const t = thickness / 2; + + if (isVertical) { + const zStart = posZ; + const zEnd = posZ + length; + // 4 угла на стыках + addFillet(posX - t, zStart, Math.PI); + addFillet(posX + t, zStart, -Math.PI/2); + addFillet(posX - t, zEnd, Math.PI/2); + addFillet(posX + t, zEnd, 0); + } else { + const xStart = posX; + const xEnd = posX + length; + addFillet(xStart, posZ - t, 0); + addFillet(xStart, posZ + t, -Math.PI/2); + addFillet(xEnd, posZ - t, Math.PI/2); + addFillet(xEnd, posZ + t, Math.PI); + } } - addWallBlock(w, h, d, x, thickness + h/2, z); }); - // 3. ПЕРФОРАЦИЯ (ВЫЧИТАНИЕ) - if (safeConfig.perforation?.enabled) { - const { pattern, diameter, spacing } = safeConfig.perforation; - // Цилиндр для вырезания (длинный, чтобы прошел насквозь) - const holeGeo = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 16); - holeGeo.rotateZ(Math.PI / 2); // По умолчанию вдоль X (для боковых стен) - - const step = diameter + Math.max(2, spacing); - const margin = 4; - - // Собираем все "сверла" в одну геометрию для скорости - const cutters: THREE.BufferGeometry[] = []; - - const generateCutters = (W: number, H: number, startX: number, startY: number, startZ: number, rotateY: boolean) => { - const cols = Math.floor((W - margin*2) / step); - const rowH = pattern === 'circle' ? step : step * 0.866; - const rows = Math.floor((H - margin*2) / rowH); - - const offsetX = (W - cols * step) / 2; - const offsetY = (H - rows * rowH) / 2; - - for(let j=0; j W - margin || hy > H - margin) continue; - - const cutter = holeGeo.clone(); - - if (rotateY) { - // Для передней/задней стенки (сверлим вдоль Z) - cutter.rotateY(Math.PI / 2); - cutter.translate(startX + hx, startY + hy, startZ); - } else { - // Для боковых стенок (сверлим вдоль X) - cutter.translate(startX, startY + hy, startZ + hx); - } - cutters.push(cutter); - } - } - }; - - // Генерируем отверстия для внешних стен - generateCutters(innerW, wallH, -innerW/2, thickness, depth/2, true); // Front - generateCutters(innerW, wallH, -innerW/2, thickness, -depth/2, true); // Back - generateCutters(depth, wallH, -width/2, thickness, -depth/2, false); // Left - generateCutters(depth, wallH, width/2, thickness, -depth/2, false); // Right - - // Применяем вычитание (SUBTRACTION) - if (cutters.length > 0) { - // Используем функцию слияния из three-stdlib (так как в core three её может не быть в старых версиях) - // Если mergeBufferGeometries не импортирован, убедитесь что он есть в импортах - const mergedCutters = mergeBufferGeometries(cutters); - if (mergedCutters) { - const cutterBrush = new Brush(mergedCutters); - cutterBrush.updateMatrixWorld(); - resultBrush = evaluator.evaluate(resultBrush, cutterBrush, SUBTRACTION); - } - } + const merged = mergeBufferGeometries(geometries); + + // Исправление нормалей (убирает прозрачность) + if (merged) { + merged.computeVertexNormals(); + return merged; } - - // Возвращаем результат - return resultBrush.geometry; + + return new THREE.BoxGeometry(1, 1, 1); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { From 1f70a7652cc3060fbdce3f13366d43df8067bced 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: Mon, 12 Jan 2026 02:12:16 +0300 Subject: [PATCH 11/21] 2 --- src/services/geometryGenerator.ts | 428 +++++++++++++----------------- 1 file changed, 183 insertions(+), 245 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 46db0fe..5dae70f 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,24 +1,31 @@ import * as THREE from 'three'; -import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; +import { STLExporter } from 'three-stdlib'; +import { SUBTRACTION, ADDITION, Brush, Evaluator } from 'three-bvh-csg'; +import { mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// 1. Собираем все перегородки из всех ячеек в один плоский список +// Очистка дубликатов точек для визуализации +const cleanPoints = (points: number[]) => { + const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); + return [...new Set(rounded)]; +}; + +// Сбор всех перегородок в один массив const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; - // Проходимся по всем ключам ("0-0", "0-1" и т.д.) и собираем массивы в один return Object.values(splits.partitions).flat(); }; +// Функция для шага 3 (отображение цветных ячеек) export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; - // Уникальные точки реза для визуализации "цветных кубиков" - const uniqueX = [0, ...safeX, 1].sort((a, b) => a - b); - const uniqueY = [0, ...safeY, 1].sort((a, b) => a - b); + const uniqueX = cleanPoints([0, ...safeX, 1]); + const uniqueY = cleanPoints([0, ...safeY, 1]); let partCounter = 1; @@ -29,7 +36,6 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; - // Фильтр микро-ячеек if (x2 - x1 < 0.001 || y2 - y1 < 0.001) continue; const rawW = (x2 - x1) * config.drawer.width; @@ -44,7 +50,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat name: `Ячейка ${partCounter}`, width: Math.max(1, rawW - gap * 2), depth: Math.max(1, rawD - gap * 2), - height: config.drawer.height - config.wallThickness, // Учитываем пол + height: config.drawer.height, x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, @@ -56,175 +62,50 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕОМЕТРИЯ --- - -// Создание формы стены с отверстиями (Правильный Winding Order!) -const createWallShapeWithHoles = (length: number, height: number, config: AppConfig): THREE.Shape => { - const shape = new THREE.Shape(); - - // 1. Контур стены (CCW - Против часовой) - shape.moveTo(0, 0); - shape.lineTo(length, 0); - shape.lineTo(length, height); - shape.lineTo(0, height); - shape.lineTo(0, 0); - - // Если перфорация выключена или стена слишком мала - if (!config.perforation?.enabled || length < 15 || height < 15) return shape; - - const { pattern, diameter, spacing } = config.perforation; - const step = diameter + Math.max(2, spacing); - const margin = 4; - - // Рабочая область - const effW = length - margin * 2; - const effH = height - margin * 2; - - if (effW <= diameter || effH <= diameter) return shape; - - // Расчет сетки - const rowH = pattern === 'circle' ? step : step * 0.866; - const cols = Math.floor(effW / step); - const rows = Math.floor(effH / rowH); - - const startX = margin + (effW - (cols - 1) * step) / 2; - const startY = margin + (effH - (rows - 1) * rowH) / 2; - - for (let j = 0; j < rows; j++) { - const isOdd = j % 2 !== 0; - const cy = startY + j * rowH; - - for (let i = 0; i < cols; i++) { - let cx = startX + i * step; - if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; - - // Проверка границ - if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || - cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; - - const hole = new THREE.Path(); - const r = diameter / 2; - - // 2. Отверстия (CW - По часовой стрелке) - // Это критически важно для Three.js, иначе дырки не вырежутся - - if (pattern === 'circle') { - hole.absarc(cx, cy, r, 0, Math.PI * 2, true); - } - else if (pattern === 'hexagon') { - for (let k = 0; k < 6; k++) { - const angle = (-k * 60 + 90) * Math.PI / 180; // Минус k = CW - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); - } - hole.closePath(); - } - else if (pattern === 'triangle') { - const rot = isOdd ? 180 : 0; - for (let k = 0; k < 3; k++) { - const angle = (-k * 120 + 90 + rot) * Math.PI / 180; - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); - } - hole.closePath(); - } - shape.holes.push(hole); - } - } - return shape; -}; - -// Форма пола (Сплошная) -const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - const x = -width / 2; - const y = -depth / 2; - const r = Math.min(radius, width / 2, depth / 2); - - if (r <= 0.1) { - shape.moveTo(x, y); - shape.lineTo(x + width, y); - shape.lineTo(x + width, y + depth); - shape.lineTo(x, y + depth); - shape.lineTo(x, y); - } else { - shape.moveTo(x, y + r); - shape.lineTo(x, y + depth - r); - shape.quadraticCurveTo(x, y + depth, x + r, y + depth); - shape.lineTo(x + width - r, y + depth); - shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; -}; - -// Форма скругления (Concave fillet) -const createFilletShape = (radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(radius, 0); - shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); - shape.lineTo(0, 0); - return shape; -}; - -// --- СБОРКА МОДЕЛИ --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (CSG) --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, - splits: LayoutSplits | Partition[] = [], // Принимаем весь объект splits + splits: LayoutSplits | Partition[] = [], config?: AppConfig ): THREE.BufferGeometry => { - const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; + const evaluator = new Evaluator(); + // Ускоряем CSG, отключая лишние проверки + evaluator.useGroups = false; - // 1. ПОЛ - const floorShape = createFloorShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // Кладем на пол - geometries.push(floorGeo); + // 1. СОЗДАЕМ "МЯСО" (Стены и пол) + // Мы собираем все прямоугольники в один массив геометрий, + // сливаем их в одну геометрию, и делаем из нее один Brush. + // Это в 10 раз быстрее, чем делать ADDITION в цикле. + + const solidParts: THREE.BufferGeometry[] = []; - // Внутренние размеры (без учета толщины внешних стен) + // ПОЛ + const floorGeo = new THREE.BoxGeometry(width, thickness, depth); + floorGeo.translate(0, thickness / 2, 0); + solidParts.push(floorGeo); + + // СТЕНЫ + const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - const wallH = height - thickness; - // 2. ВНЕШНИЕ СТЕНКИ - // Создаем 2D профили с дырками - const shapeFrontBack = createWallShapeWithHoles(innerW, wallH, safeConfig); - const shapeLeftRight = createWallShapeWithHoles(depth, wallH, safeConfig); // Боковые на всю глубину + // Хелпер для создания куба стены + const addWall = (w: number, h: number, d: number, x: number, y: number, z: number) => { + const geo = new THREE.BoxGeometry(w, h, d); + geo.translate(x, y, z); + solidParts.push(geo); + }; - // Front (Спереди) - const geoF = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); - geoF.translate(-innerW/2, thickness, depth/2 - thickness); - geometries.push(geoF); + // Внешние стены + addWall(innerW, wallH, thickness, 0, thickness + wallH/2, depth/2 - thickness/2); // Front + addWall(innerW, wallH, thickness, 0, thickness + wallH/2, -depth/2 + thickness/2); // Back + addWall(thickness, wallH, depth, -width/2 + thickness/2, thickness + wallH/2, 0); // Left + addWall(thickness, wallH, depth, width/2 - thickness/2, thickness + wallH/2, 0); // Right - // Back (Сзади) - const geoB = new THREE.ExtrudeGeometry(shapeFrontBack, { depth: thickness, bevelEnabled: false }); - geoB.translate(-innerW/2, thickness, -depth/2); - geometries.push(geoB); - - // Left (Слева) - const geoL = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); - geoL.rotateY(Math.PI / 2); - geoL.translate(-width/2, thickness, -depth/2); - geometries.push(geoL); - - // Right (Справа) - const geoR = new THREE.ExtrudeGeometry(shapeLeftRight, { depth: thickness, bevelEnabled: false }); - geoR.rotateY(Math.PI / 2); - geoR.translate(width/2 - thickness, thickness, -depth/2); - geometries.push(geoR); - - - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - // Важно: извлекаем плоский массив стенок + // Внутренние стены let partitions: Partition[] = []; if (Array.isArray(splits)) { partitions = splits; @@ -235,95 +116,152 @@ export const createBinGeometry = ( partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - - // Игнорируем ошибки данных if (pMax - pMin < 0.001) return; - let length = 0; - let posX = 0; - let posZ = 0; - let isVertical = false; + let w=0, h=p.height, d=0, x=0, z=0; - // Рассчитываем координаты и размеры - if (p.axis === 'x') { - // Вертикальная на экране (Вдоль Z) - isVertical = true; - length = (pMax - pMin) * innerD; - // X: центр линии - posX = (-innerW/2) + (p.offset * innerW); - // Z: начало линии - posZ = (-innerD/2) + (pMin * innerD); - } else { - // Горизонтальная на экране (Вдоль X) - isVertical = false; - length = (pMax - pMin) * innerW; - // X: начало линии - posX = (-innerW/2) + (pMin * innerW); - // Z: центр линии - posZ = (-innerD/2) + (p.offset * innerD); - } - - // Создаем стенку с дырками - const partShape = createWallShapeWithHoles(length, wallH, safeConfig); - const partGeo = new THREE.ExtrudeGeometry(partShape, { depth: thickness, bevelEnabled: false }); - - if (isVertical) { - // Поворачиваем вдоль Z - partGeo.rotateY(Math.PI / 2); - // Смещаем: X - половина толщины (для центровки), Y=thick, Z=начало - partGeo.translate(posX - thickness/2, thickness, posZ); - } else { - // Вдоль X - // Смещаем: X=начало, Y=thick, Z - половина толщины - partGeo.translate(posX, thickness, posZ - thickness/2); - } - - geometries.push(partGeo); - - // --- СКРУГЛЕНИЯ (FILLETS) --- - if (p.rounded && radius > 1) { - const fR = Math.min(radius, 5); - const fShape = createFilletShape(fR); - const h = p.height; - - const addFillet = (fx: number, fz: number, rot: number) => { - const geo = new THREE.ExtrudeGeometry(fShape, { depth: h, bevelEnabled: false }); - geo.rotateX(-Math.PI / 2); - geo.rotateY(rot); - geo.translate(fx, thickness, fz); - geometries.push(geo); - }; - - const t = thickness / 2; - - if (isVertical) { - const zStart = posZ; - const zEnd = posZ + length; - // 4 угла на стыках - addFillet(posX - t, zStart, Math.PI); - addFillet(posX + t, zStart, -Math.PI/2); - addFillet(posX - t, zEnd, Math.PI/2); - addFillet(posX + t, zEnd, 0); - } else { - const xStart = posX; - const xEnd = posX + length; - addFillet(xStart, posZ - t, 0); - addFillet(xStart, posZ + t, -Math.PI/2); - addFillet(xEnd, posZ - t, Math.PI/2); - addFillet(xEnd, posZ + t, Math.PI); - } + if (p.axis === 'x') { // Вертикальная (вдоль Z) + w = thickness; + d = (pMax - pMin) * innerD; + x = (-innerW/2) + (p.offset * innerW); + z = (-innerD/2) + (pMin * innerD) + (d/2); + } else { // Горизонтальная (вдоль X) + w = (pMax - pMin) * innerW; + d = thickness; + x = (-innerW/2) + (pMin * innerW) + (w/2); + z = (-innerD/2) + (p.offset * innerD); } + addWall(w, h, d, x, thickness + h/2, z); }); - const merged = mergeBufferGeometries(geometries); - - // Исправление нормалей (убирает прозрачность) - if (merged) { - merged.computeVertexNormals(); - return merged; + // Объединяем всю твердую геометрию в один Mesh + const mergedSolids = mergeBufferGeometries(solidParts); + let mainBrush = new Brush(mergedSolids); + mainBrush.updateMatrixWorld(); + + // 2. ПЕРФОРАЦИЯ (ЕСЛИ ВКЛЮЧЕНА) + if (safeConfig.perforation?.enabled) { + const { pattern, diameter, spacing } = safeConfig.perforation; + const step = diameter + Math.max(2, spacing); + const margin = 4; + + // Создаем базовые "сверла" + const drillZ = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 12); + drillZ.rotateX(Math.PI / 2); // Сверлит вдоль Z (для стен вдоль X) + + const drillX = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 12); + drillX.rotateZ(Math.PI / 2); // Сверлит вдоль X (для стен вдоль Z) + + const cutterParts: THREE.BufferGeometry[] = []; + + // Функция расстановки сверл на плоскости + const drillWall = (W: number, H: number, startX: number, startY: number, startZ: number, axis: 'x' | 'z') => { + const cols = Math.floor((W - margin*2) / step); + const rowH = pattern === 'circle' ? step : step * 0.866; + const rows = Math.floor((H - margin*2) / rowH); + + const offsetX = (W - cols * step) / 2; + const offsetY = (H - rows * rowH) / 2; + + for(let r=0; r W - margin || v > H - margin) continue; + + let drill: THREE.BufferGeometry; + + if (axis === 'x') { + // Стена вдоль X (Front/Back/Horiz). Сверлим вдоль Z. + // U = X, V = Y. + drill = drillZ.clone(); + drill.translate(startX + u, startY + v, startZ); + } else { + // Стена вдоль Z (Left/Right/Vert). Сверлим вдоль X. + // U = Z, V = Y. + drill = drillX.clone(); + drill.translate(startX, startY + v, startZ + u); + } + cutterParts.push(drill); + } + } + }; + + // Генерируем сверла для внешних стен + // Front (X-wall) + drillWall(innerW, wallH, -innerW/2, thickness, depth/2, 'x'); + // Back (X-wall) + drillWall(innerW, wallH, -innerW/2, thickness, -depth/2, 'x'); + // Left (Z-wall) + drillWall(depth, wallH, -width/2, thickness, -depth/2, 'z'); + // Right (Z-wall) + drillWall(depth, wallH, width/2, thickness, -depth/2, 'z'); + + // Генерируем сверла для ВНУТРЕННИХ стен + partitions.forEach(p => { + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + if (pMax - pMin < 0.001) return; + + if (p.axis === 'x') { // Vert wall (Z-axis) + const len = (pMax - pMin) * innerD; + const xPos = (-innerW/2) + (p.offset * innerW); + const zStart = (-innerD/2) + (pMin * innerD); + drillWall(len, p.height, xPos, thickness, zStart, 'z'); + } else { // Horiz wall (X-axis) + const len = (pMax - pMin) * innerW; + const xStart = (-innerW/2) + (pMin * innerW); + const zPos = (-innerD/2) + (p.offset * innerD); + drillWall(len, p.height, xStart, thickness, zPos, 'x'); + } + }); + + // ВЫЧИТАНИЕ + if (cutterParts.length > 0) { + const mergedCutters = mergeBufferGeometries(cutterParts); + const cutterBrush = new Brush(mergedCutters); + cutterBrush.updateMatrixWorld(); + + // SOLID - CUTTERS + mainBrush = evaluator.evaluate(mainBrush, cutterBrush, SUBTRACTION); + } } - - return new THREE.BoxGeometry(1, 1, 1); + + // 3. СКРУГЛЕНИЯ (ДОБАВЛЕНИЕ) + if (radius > 0) { + const filletParts: THREE.BufferGeometry[] = []; + const fRad = Math.min(radius, 5); + const filletGeo = new THREE.CylinderGeometry(fRad, fRad, 1, 16, 1, false, 0, Math.PI/2); // Четверть цилиндра + // Центрируем пивот для удобства + filletGeo.translate(0, 0.5, 0); // Y вверх 0..1 + + // Хелпер для добавления скругления + const addFillet = (x: number, y: number, z: number, h: number, rotY: number) => { + const f = filletGeo.clone(); + f.scale(1, h, 1); // Масштабируем по высоте + // Поворот + f.rotateY(rotY); + f.translate(x, y, z); + filletParts.push(f); + }; + + // Проходим по стыкам (упрощенно: вертикальные столбики в углах примыканий) + // В данной реализации CSG проще всего добавить цилиндры в углы, чтобы "залить" их. + // Но так как мы используем ADDITION для стен, углы уже залиты (острые). + // Чтобы сделать *вогнутые* скругления (Fillet), нужно делать UNION специальных форм. + + // Для скорости и надежности, пока оставим острые внутренние углы, если они получены через ADDITION. + // Если нужны именно вогнутые скругления, нужно добавлять "призмы" и вычитать цилиндры, это сложно. + // Если нужны выпуклые скругления внешних углов - это просто. + + // Оставим пока без доп. геометрии для скруглений, так как ADDITION уже делает герметичный стык. + // Если критично именно *визуальное* скругление, можно добавить цилиндры. + } + + return mainBrush.geometry; }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { From 7d258ff575ccfb79d30c55c592b3ec25600f12dc 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: Mon, 12 Jan 2026 02:21:17 +0300 Subject: [PATCH 12/21] 3 --- src/services/geometryGenerator.ts | 235 ++++++++++++++++-------------- 1 file changed, 129 insertions(+), 106 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 5dae70f..2a275f1 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -6,19 +6,18 @@ import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// Очистка дубликатов точек для визуализации const cleanPoints = (points: number[]) => { const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); return [...new Set(rounded)]; }; -// Сбор всех перегородок в один массив const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; + // Собираем все массивы перегородок в один плоский массив return Object.values(splits.partitions).flat(); }; -// Функция для шага 3 (отображение цветных ячеек) +// Функция для предпросмотра (шаг 3 - цветные блоки) export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; @@ -62,7 +61,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (CSG) --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (CSG FIXED) --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, @@ -72,40 +71,42 @@ export const createBinGeometry = ( const safeConfig = config || { perforation: { enabled: false } } as AppConfig; const evaluator = new Evaluator(); - // Ускоряем CSG, отключая лишние проверки - evaluator.useGroups = false; + evaluator.useGroups = false; // Важно для корректного слияния материалов и нормалей - // 1. СОЗДАЕМ "МЯСО" (Стены и пол) - // Мы собираем все прямоугольники в один массив геометрий, - // сливаем их в одну геометрию, и делаем из нее один Brush. - // Это в 10 раз быстрее, чем делать ADDITION в цикле. - - const solidParts: THREE.BufferGeometry[] = []; + // Массивы для хранения геометрии перед слиянием + const solidGeometries: THREE.BufferGeometry[] = []; + const holeGeometries: THREE.BufferGeometry[] = []; + const filletGeometries: THREE.BufferGeometry[] = []; - // ПОЛ + // ========================================== + // 1. СБОРКА ТВЕРДЫХ ТЕЛ (ПОЛ + СТЕНЫ) + // ========================================== + + // 1.1 ПОЛ const floorGeo = new THREE.BoxGeometry(width, thickness, depth); floorGeo.translate(0, thickness / 2, 0); - solidParts.push(floorGeo); + solidGeometries.push(floorGeo); - // СТЕНЫ const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // Хелпер для создания куба стены - const addWall = (w: number, h: number, d: number, x: number, y: number, z: number) => { + // Функция добавления геометрии стены + const addWallGeometry = (w: number, h: number, d: number, x: number, y: number, z: number) => { const geo = new THREE.BoxGeometry(w, h, d); geo.translate(x, y, z); - solidParts.push(geo); + solidGeometries.push(geo); }; - // Внешние стены - addWall(innerW, wallH, thickness, 0, thickness + wallH/2, depth/2 - thickness/2); // Front - addWall(innerW, wallH, thickness, 0, thickness + wallH/2, -depth/2 + thickness/2); // Back - addWall(thickness, wallH, depth, -width/2 + thickness/2, thickness + wallH/2, 0); // Left - addWall(thickness, wallH, depth, width/2 - thickness/2, thickness + wallH/2, 0); // Right + // 1.2 ВНЕШНИЕ СТЕНЫ + // Front & Back (Вдоль X) + addWallGeometry(innerW, wallH, thickness, 0, thickness + wallH/2, depth/2 - thickness/2); + addWallGeometry(innerW, wallH, thickness, 0, thickness + wallH/2, -depth/2 + thickness/2); + // Left & Right (Вдоль Z) + addWallGeometry(thickness, wallH, depth, -width/2 + thickness/2, thickness + wallH/2, 0); + addWallGeometry(thickness, wallH, depth, width/2 - thickness/2, thickness + wallH/2, 0); - // Внутренние стены + // 1.3 ВНУТРЕННИЕ ПЕРЕГОРОДКИ let partitions: Partition[] = []; if (Array.isArray(splits)) { partitions = splits; @@ -116,50 +117,94 @@ export const createBinGeometry = ( partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - if (pMax - pMin < 0.001) return; + + // Игнорируем ошибки данных + if (Math.abs(pMax - pMin) < 0.001) return; let w=0, h=p.height, d=0, x=0, z=0; - if (p.axis === 'x') { // Вертикальная (вдоль Z) + if (p.axis === 'x') { // Вертикальная (Вдоль Z) w = thickness; d = (pMax - pMin) * innerD; x = (-innerW/2) + (p.offset * innerW); - z = (-innerD/2) + (pMin * innerD) + (d/2); - } else { // Горизонтальная (вдоль X) + const midZ = (pMin + pMax) / 2; + z = (-innerD/2) + (midZ * innerD); + } else { // Горизонтальная (Вдоль X) w = (pMax - pMin) * innerW; d = thickness; - x = (-innerW/2) + (pMin * innerW) + (w/2); + const midX = (pMin + pMax) / 2; + x = (-innerW/2) + (midX * innerW); z = (-innerD/2) + (p.offset * innerD); } - addWall(w, h, d, x, thickness + h/2, z); + addWallGeometry(w, h, d, x, thickness + h/2, z); }); - // Объединяем всю твердую геометрию в один Mesh - const mergedSolids = mergeBufferGeometries(solidParts); - let mainBrush = new Brush(mergedSolids); - mainBrush.updateMatrixWorld(); + // ========================================== + // 2. СБОРКА СКРУГЛЕНИЙ (ADDITION) + // ========================================== + + if (radius > 0) { + const fRad = Math.min(radius, 5); + // Используем цилиндр для сглаживания углов (выпуклое скругление внутренних углов) + // Для настоящего вогнутого fillet в CSG нужно вычитать обратную форму, но для FDM печати + // добавление материала в угол (chamfer/fillet) часто лучше. + // Делаем просто цилиндры в местах стыков. + const filletCyl = new THREE.CylinderGeometry(fRad, fRad, 1, 16); + + const addFillet = (x: number, z: number, h: number) => { + const f = filletCyl.clone(); + f.scale(1, h, 1); + f.translate(x, thickness + h/2, z); + filletGeometries.push(f); + }; + + partitions.forEach(p => { + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + if (Math.abs(pMax - pMin) < 0.001) return; + + if (!p.rounded) return; // Пропускаем если скругление выключено для этой стенки + + if (p.axis === 'x') { // Vert + const xPos = (-innerW/2) + (p.offset * innerW); + const zStart = (-innerD/2) + (pMin * innerD); + const zEnd = (-innerD/2) + (pMax * innerD); + addFillet(xPos, zStart, p.height); + addFillet(xPos, zEnd, p.height); + } else { // Horiz + const zPos = (-innerD/2) + (p.offset * innerD); + const xStart = (-innerW/2) + (pMin * innerW); + const xEnd = (-innerW/2) + (pMax * innerW); + addFillet(xStart, zPos, p.height); + addFillet(xEnd, zPos, p.height); + } + }); + } + + // ========================================== + // 3. СБОРКА ОТВЕРСТИЙ (SUBTRACTION) + // ========================================== - // 2. ПЕРФОРАЦИЯ (ЕСЛИ ВКЛЮЧЕНА) if (safeConfig.perforation?.enabled) { const { pattern, diameter, spacing } = safeConfig.perforation; - const step = diameter + Math.max(2, spacing); const margin = 4; + const step = diameter + Math.max(2, spacing); + + // Базовые сверла (длинные, чтобы пробить насквозь) + const drillLength = thickness * 4; + const drillZ = new THREE.CylinderGeometry(diameter/2, diameter/2, drillLength, 12); + drillZ.rotateX(Math.PI / 2); // Вдоль Z + const drillX = new THREE.CylinderGeometry(diameter/2, diameter/2, drillLength, 12); + drillX.rotateZ(Math.PI / 2); // Вдоль X - // Создаем базовые "сверла" - const drillZ = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 12); - drillZ.rotateX(Math.PI / 2); // Сверлит вдоль Z (для стен вдоль X) - - const drillX = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 4, 12); - drillX.rotateZ(Math.PI / 2); // Сверлит вдоль X (для стен вдоль Z) - - const cutterParts: THREE.BufferGeometry[] = []; - - // Функция расстановки сверл на плоскости - const drillWall = (W: number, H: number, startX: number, startY: number, startZ: number, axis: 'x' | 'z') => { + // Функция генерации массива сверл для плоскости + const createDrills = (W: number, H: number, startX: number, startY: number, startZ: number, axis: 'x' | 'z') => { const cols = Math.floor((W - margin*2) / step); const rowH = pattern === 'circle' ? step : step * 0.866; const rows = Math.floor((H - margin*2) / rowH); + if (cols <= 0 || rows <= 0) return; + const offsetX = (W - cols * step) / 2; const offsetY = (H - rows * rowH) / 2; @@ -173,95 +218,73 @@ export const createBinGeometry = ( if (u > W - margin || v > H - margin) continue; let drill: THREE.BufferGeometry; - if (axis === 'x') { - // Стена вдоль X (Front/Back/Horiz). Сверлим вдоль Z. - // U = X, V = Y. + // Стена вдоль X. Сверлим ВДОЛЬ Z. drill = drillZ.clone(); drill.translate(startX + u, startY + v, startZ); } else { - // Стена вдоль Z (Left/Right/Vert). Сверлим вдоль X. - // U = Z, V = Y. + // Стена вдоль Z. Сверлим ВДОЛЬ X. drill = drillX.clone(); drill.translate(startX, startY + v, startZ + u); } - cutterParts.push(drill); + holeGeometries.push(drill); } } }; - // Генерируем сверла для внешних стен - // Front (X-wall) - drillWall(innerW, wallH, -innerW/2, thickness, depth/2, 'x'); - // Back (X-wall) - drillWall(innerW, wallH, -innerW/2, thickness, -depth/2, 'x'); - // Left (Z-wall) - drillWall(depth, wallH, -width/2, thickness, -depth/2, 'z'); - // Right (Z-wall) - drillWall(depth, wallH, width/2, thickness, -depth/2, 'z'); + // 3.1 Сверлим внешние стены + // Front & Back + createDrills(innerW, wallH, -innerW/2, thickness, depth/2, 'x'); + createDrills(innerW, wallH, -innerW/2, thickness, -depth/2, 'x'); + // Left & Right + createDrills(depth, wallH, -width/2, thickness, -depth/2, 'z'); + createDrills(depth, wallH, width/2, thickness, -depth/2, 'z'); - // Генерируем сверла для ВНУТРЕННИХ стен + // 3.2 Сверлим внутренние перегородки partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - if (pMax - pMin < 0.001) return; + if (Math.abs(pMax - pMin) < 0.001) return; if (p.axis === 'x') { // Vert wall (Z-axis) const len = (pMax - pMin) * innerD; const xPos = (-innerW/2) + (p.offset * innerW); const zStart = (-innerD/2) + (pMin * innerD); - drillWall(len, p.height, xPos, thickness, zStart, 'z'); + createDrills(len, p.height, xPos, thickness, zStart, 'z'); } else { // Horiz wall (X-axis) const len = (pMax - pMin) * innerW; const xStart = (-innerW/2) + (pMin * innerW); const zPos = (-innerD/2) + (p.offset * innerD); - drillWall(len, p.height, xStart, thickness, zPos, 'x'); + createDrills(len, p.height, xStart, thickness, zPos, 'x'); } }); + } - // ВЫЧИТАНИЕ - if (cutterParts.length > 0) { - const mergedCutters = mergeBufferGeometries(cutterParts); - const cutterBrush = new Brush(mergedCutters); - cutterBrush.updateMatrixWorld(); - - // SOLID - CUTTERS - mainBrush = evaluator.evaluate(mainBrush, cutterBrush, SUBTRACTION); + // ========================================== + // 4. ФИНАЛЬНЫЕ ОПЕРАЦИИ CSG + // ========================================== + + // Объединяем всю твердую геометрию + const mergedSolids = mergeBufferGeometries([...solidGeometries, ...filletGeometries]); + if (!mergedSolids) return new THREE.BoxGeometry(1,1,1); // Fallback + + let finalBrush = new Brush(mergedSolids); + finalBrush.updateMatrixWorld(); + + // Если есть отверстия, вычитаем их + if (holeGeometries.length > 0) { + const mergedHoles = mergeBufferGeometries(holeGeometries); + if (mergedHoles) { + const holeBrush = new Brush(mergedHoles); + holeBrush.updateMatrixWorld(); + finalBrush = evaluator.evaluate(finalBrush, holeBrush, SUBTRACTION); } } - // 3. СКРУГЛЕНИЯ (ДОБАВЛЕНИЕ) - if (radius > 0) { - const filletParts: THREE.BufferGeometry[] = []; - const fRad = Math.min(radius, 5); - const filletGeo = new THREE.CylinderGeometry(fRad, fRad, 1, 16, 1, false, 0, Math.PI/2); // Четверть цилиндра - // Центрируем пивот для удобства - filletGeo.translate(0, 0.5, 0); // Y вверх 0..1 - - // Хелпер для добавления скругления - const addFillet = (x: number, y: number, z: number, h: number, rotY: number) => { - const f = filletGeo.clone(); - f.scale(1, h, 1); // Масштабируем по высоте - // Поворот - f.rotateY(rotY); - f.translate(x, y, z); - filletParts.push(f); - }; - - // Проходим по стыкам (упрощенно: вертикальные столбики в углах примыканий) - // В данной реализации CSG проще всего добавить цилиндры в углы, чтобы "залить" их. - // Но так как мы используем ADDITION для стен, углы уже залиты (острые). - // Чтобы сделать *вогнутые* скругления (Fillet), нужно делать UNION специальных форм. - - // Для скорости и надежности, пока оставим острые внутренние углы, если они получены через ADDITION. - // Если нужны именно вогнутые скругления, нужно добавлять "призмы" и вычитать цилиндры, это сложно. - // Если нужны выпуклые скругления внешних углов - это просто. - - // Оставим пока без доп. геометрии для скруглений, так как ADDITION уже делает герметичный стык. - // Если критично именно *визуальное* скругление, можно добавить цилиндры. - } - - return mainBrush.geometry; + // Обновляем нормали для корректного отображения света (убирает прозрачность) + finalBrush.geometry.computeVertexNormals(); + + return finalBrush.geometry; }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { From 03a787a459fb6f5239685980fc5ab71e1b858e22 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: Mon, 12 Jan 2026 02:27:57 +0300 Subject: [PATCH 13/21] 4 --- src/services/geometryGenerator.ts | 317 ++++++++++++------------------ 1 file changed, 131 insertions(+), 186 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 2a275f1..1171577 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -13,11 +13,9 @@ const cleanPoints = (points: number[]) => { const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; - // Собираем все массивы перегородок в один плоский массив return Object.values(splits.partitions).flat(); }; -// Функция для предпросмотра (шаг 3 - цветные блоки) export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; @@ -61,7 +59,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (CSG FIXED) --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (ПОСЛЕДОВАТЕЛЬНЫЙ CSG) --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, @@ -71,220 +69,167 @@ export const createBinGeometry = ( const safeConfig = config || { perforation: { enabled: false } } as AppConfig; const evaluator = new Evaluator(); - evaluator.useGroups = false; // Важно для корректного слияния материалов и нормалей - - // Массивы для хранения геометрии перед слиянием - const solidGeometries: THREE.BufferGeometry[] = []; - const holeGeometries: THREE.BufferGeometry[] = []; - const filletGeometries: THREE.BufferGeometry[] = []; - - // ========================================== - // 1. СБОРКА ТВЕРДЫХ ТЕЛ (ПОЛ + СТЕНЫ) - // ========================================== - - // 1.1 ПОЛ - const floorGeo = new THREE.BoxGeometry(width, thickness, depth); - floorGeo.translate(0, thickness / 2, 0); - solidGeometries.push(floorGeo); + evaluator.useGroups = false; const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // Функция добавления геометрии стены - const addWallGeometry = (w: number, h: number, d: number, x: number, y: number, z: number) => { - const geo = new THREE.BoxGeometry(w, h, d); - geo.translate(x, y, z); - solidGeometries.push(geo); + // 1. Базовая геометрия - ПОЛ + const floorGeo = new THREE.BoxGeometry(width, thickness, depth); + floorGeo.translate(0, thickness / 2, 0); + let mainBrush = new Brush(floorGeo); + mainBrush.updateMatrixWorld(); + + // --- ФУНКЦИИ ОПЕРАЦИЙ --- + + // Добавить твердое тело (стену/скругление) + const addSolid = (geo: THREE.BufferGeometry) => { + const brush = new Brush(geo); + brush.updateMatrixWorld(); + mainBrush = evaluator.evaluate(mainBrush, brush, ADDITION); }; - // 1.2 ВНЕШНИЕ СТЕНЫ - // Front & Back (Вдоль X) - addWallGeometry(innerW, wallH, thickness, 0, thickness + wallH/2, depth/2 - thickness/2); - addWallGeometry(innerW, wallH, thickness, 0, thickness + wallH/2, -depth/2 + thickness/2); - // Left & Right (Вдоль Z) - addWallGeometry(thickness, wallH, depth, -width/2 + thickness/2, thickness + wallH/2, 0); - addWallGeometry(thickness, wallH, depth, width/2 - thickness/2, thickness + wallH/2, 0); - - // 1.3 ВНУТРЕННИЕ ПЕРЕГОРОДКИ - let partitions: Partition[] = []; - if (Array.isArray(splits)) { - partitions = splits; - } else if (splits && splits.partitions) { - partitions = getAllPartitions(splits); - } - - partitions.forEach(p => { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; - - // Игнорируем ошибки данных - if (Math.abs(pMax - pMin) < 0.001) return; - - let w=0, h=p.height, d=0, x=0, z=0; - - if (p.axis === 'x') { // Вертикальная (Вдоль Z) - w = thickness; - d = (pMax - pMin) * innerD; - x = (-innerW/2) + (p.offset * innerW); - const midZ = (pMin + pMax) / 2; - z = (-innerD/2) + (midZ * innerD); - } else { // Горизонтальная (Вдоль X) - w = (pMax - pMin) * innerW; - d = thickness; - const midX = (pMin + pMax) / 2; - x = (-innerW/2) + (midX * innerW); - z = (-innerD/2) + (p.offset * innerD); - } - addWallGeometry(w, h, d, x, thickness + h/2, z); - }); - - // ========================================== - // 2. СБОРКА СКРУГЛЕНИЙ (ADDITION) - // ========================================== - - if (radius > 0) { - const fRad = Math.min(radius, 5); - // Используем цилиндр для сглаживания углов (выпуклое скругление внутренних углов) - // Для настоящего вогнутого fillet в CSG нужно вычитать обратную форму, но для FDM печати - // добавление материала в угол (chamfer/fillet) часто лучше. - // Делаем просто цилиндры в местах стыков. - const filletCyl = new THREE.CylinderGeometry(fRad, fRad, 1, 16); - - const addFillet = (x: number, z: number, h: number) => { - const f = filletCyl.clone(); - f.scale(1, h, 1); - f.translate(x, thickness + h/2, z); - filletGeometries.push(f); - }; - - partitions.forEach(p => { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; - if (Math.abs(pMax - pMin) < 0.001) return; - - if (!p.rounded) return; // Пропускаем если скругление выключено для этой стенки - - if (p.axis === 'x') { // Vert - const xPos = (-innerW/2) + (p.offset * innerW); - const zStart = (-innerD/2) + (pMin * innerD); - const zEnd = (-innerD/2) + (pMax * innerD); - addFillet(xPos, zStart, p.height); - addFillet(xPos, zEnd, p.height); - } else { // Horiz - const zPos = (-innerD/2) + (p.offset * innerD); - const xStart = (-innerW/2) + (pMin * innerW); - const xEnd = (-innerW/2) + (pMax * innerW); - addFillet(xStart, zPos, p.height); - addFillet(xEnd, zPos, p.height); - } - }); - } - - // ========================================== - // 3. СБОРКА ОТВЕРСТИЙ (SUBTRACTION) - // ========================================== - - if (safeConfig.perforation?.enabled) { + // Вычесть отверстия для конкретной зоны + const subtractHoles = (W: number, H: number, startX: number, startY: number, startZ: number, axis: 'x' | 'z') => { + if (!safeConfig.perforation?.enabled) return; const { pattern, diameter, spacing } = safeConfig.perforation; const margin = 4; const step = diameter + Math.max(2, spacing); - // Базовые сверла (длинные, чтобы пробить насквозь) - const drillLength = thickness * 4; - const drillZ = new THREE.CylinderGeometry(diameter/2, diameter/2, drillLength, 12); - drillZ.rotateX(Math.PI / 2); // Вдоль Z - const drillX = new THREE.CylinderGeometry(diameter/2, diameter/2, drillLength, 12); - drillX.rotateZ(Math.PI / 2); // Вдоль X + const cols = Math.floor((W - margin*2) / step); + const rowH = pattern === 'circle' ? step : step * 0.866; + const rows = Math.floor((H - margin*2) / rowH); - // Функция генерации массива сверл для плоскости - const createDrills = (W: number, H: number, startX: number, startY: number, startZ: number, axis: 'x' | 'z') => { - const cols = Math.floor((W - margin*2) / step); - const rowH = pattern === 'circle' ? step : step * 0.866; - const rows = Math.floor((H - margin*2) / rowH); + if (cols <= 0 || rows <= 0) return; - if (cols <= 0 || rows <= 0) return; + const offsetX = (W - cols * step) / 2; + const offsetY = (H - rows * rowH) / 2; - const offsetX = (W - cols * step) / 2; - const offsetY = (H - rows * rowH) / 2; + // Базовое сверло + const drillBase = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 3, 12); + if (axis === 'x') drillBase.rotateX(Math.PI / 2); // Вдоль Z + else drillBase.rotateZ(Math.PI / 2); // Вдоль X - for(let r=0; r W - margin || v > H - margin) continue; + const drills: THREE.BufferGeometry[] = []; - let drill: THREE.BufferGeometry; - if (axis === 'x') { - // Стена вдоль X. Сверлим ВДОЛЬ Z. - drill = drillZ.clone(); - drill.translate(startX + u, startY + v, startZ); - } else { - // Стена вдоль Z. Сверлим ВДОЛЬ X. - drill = drillX.clone(); - drill.translate(startX, startY + v, startZ + u); - } - holeGeometries.push(drill); - } + for(let r=0; r W - margin || v > H - margin) continue; + + const drill = drillBase.clone(); + if (axis === 'x') drill.translate(startX + u, startY + v, startZ); + else drill.translate(startX, startY + v, startZ + u); + drills.push(drill); } - }; + } - // 3.1 Сверлим внешние стены - // Front & Back - createDrills(innerW, wallH, -innerW/2, thickness, depth/2, 'x'); - createDrills(innerW, wallH, -innerW/2, thickness, -depth/2, 'x'); - // Left & Right - createDrills(depth, wallH, -width/2, thickness, -depth/2, 'z'); - createDrills(depth, wallH, width/2, thickness, -depth/2, 'z'); + if (drills.length > 0) { + const mergedDrills = mergeBufferGeometries(drills); + if (mergedDrills) { + const drillBrush = new Brush(mergedDrills); + drillBrush.updateMatrixWorld(); + mainBrush = evaluator.evaluate(mainBrush, drillBrush, SUBTRACTION); + } + } + }; + + + // 2. СБОРКА СТЕН (ADDITION) + + // Внешние стены + // Front + addSolid(new THREE.BoxGeometry(innerW, wallH, thickness).translate(0, thickness + wallH/2, depth/2 - thickness/2)); + // Back + addSolid(new THREE.BoxGeometry(innerW, wallH, thickness).translate(0, thickness + wallH/2, -depth/2 + thickness/2)); + // Left + addSolid(new THREE.BoxGeometry(thickness, wallH, depth).translate(-width/2 + thickness/2, thickness + wallH/2, 0)); + // Right + addSolid(new THREE.BoxGeometry(thickness, wallH, depth).translate(width/2 - thickness/2, thickness + wallH/2, 0)); + + // Внутренние перегородки + let partitions: Partition[] = []; + if (Array.isArray(splits)) partitions = splits; + else if (splits && splits.partitions) partitions = getAllPartitions(splits); + + partitions.forEach(p => { + const pMin = p.min ?? 0; const pMax = p.max ?? 1; + if (Math.abs(pMax - pMin) < 0.001) return; + + let w=0, h=p.height, d=0, x=0, z=0; + if (p.axis === 'x') { // Vert (Z-axis) + w = thickness; d = (pMax - pMin) * innerD; + x = (-innerW/2) + (p.offset * innerW); + z = (-innerD/2) + ((pMin + pMax)/2 * innerD); + } else { // Horiz (X-axis) + w = (pMax - pMin) * innerW; d = thickness; + x = (-innerW/2) + ((pMin + pMax)/2 * innerW); + z = (-innerD/2) + (p.offset * innerD); + } + addSolid(new THREE.BoxGeometry(w, h, d).translate(x, thickness + h/2, z)); + }); + + // 3. СКРУГЛЕНИЯ (ADDITION - цилиндры в углы) + if (radius > 0) { + const fRad = Math.min(radius, 5); + const filletBase = new THREE.CylinderGeometry(fRad, fRad, 1, 16); + filletBase.translate(0, 0.5, 0); // Pivot at bottom - // 3.2 Сверлим внутренние перегородки partitions.forEach(p => { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; + if (!p.rounded) return; + const pMin = p.min ?? 0; const pMax = p.max ?? 1; if (Math.abs(pMax - pMin) < 0.001) return; - if (p.axis === 'x') { // Vert wall (Z-axis) - const len = (pMax - pMin) * innerD; + const addF = (x: number, z: number) => { + const f = filletBase.clone(); + f.scale(1, p.height, 1); + f.translate(x, thickness, z); + addSolid(f); + }; + + if (p.axis === 'x') { // Vert const xPos = (-innerW/2) + (p.offset * innerW); - const zStart = (-innerD/2) + (pMin * innerD); - createDrills(len, p.height, xPos, thickness, zStart, 'z'); - } else { // Horiz wall (X-axis) - const len = (pMax - pMin) * innerW; - const xStart = (-innerW/2) + (pMin * innerW); + addF(xPos, (-innerD/2) + (pMin * innerD)); // Start Z + addF(xPos, (-innerD/2) + (pMax * innerD)); // End Z + } else { // Horiz const zPos = (-innerD/2) + (p.offset * innerD); - createDrills(len, p.height, xStart, thickness, zPos, 'x'); + addF((-innerW/2) + (pMin * innerW), zPos); // Start X + addF((-innerW/2) + (pMax * innerW), zPos); // End X } }); } - // ========================================== - // 4. ФИНАЛЬНЫЕ ОПЕРАЦИИ CSG - // ========================================== + // 4. ПЕРФОРАЦИЯ (SUBTRACTION) + if (safeConfig.perforation?.enabled) { + // Внешние стены + subtractHoles(innerW, wallH, -innerW/2, thickness, depth/2, 'x'); // Front + subtractHoles(innerW, wallH, -innerW/2, thickness, -depth/2, 'x'); // Back + subtractHoles(depth, wallH, -width/2, thickness, -depth/2, 'z'); // Left + subtractHoles(depth, wallH, width/2, thickness, -depth/2, 'z'); // Right - // Объединяем всю твердую геометрию - const mergedSolids = mergeBufferGeometries([...solidGeometries, ...filletGeometries]); - if (!mergedSolids) return new THREE.BoxGeometry(1,1,1); // Fallback - - let finalBrush = new Brush(mergedSolids); - finalBrush.updateMatrixWorld(); - - // Если есть отверстия, вычитаем их - if (holeGeometries.length > 0) { - const mergedHoles = mergeBufferGeometries(holeGeometries); - if (mergedHoles) { - const holeBrush = new Brush(mergedHoles); - holeBrush.updateMatrixWorld(); - finalBrush = evaluator.evaluate(finalBrush, holeBrush, SUBTRACTION); - } + // Внутренние перегородки + partitions.forEach(p => { + const pMin = p.min ?? 0; const pMax = p.max ?? 1; + if (Math.abs(pMax - pMin) < 0.001) return; + if (p.axis === 'x') { + const len = (pMax - pMin) * innerD; + const xPos = (-innerW/2) + (p.offset * innerW); + const zStart = (-innerD/2) + (pMin * innerD); + subtractHoles(len, p.height, xPos, thickness, zStart, 'z'); + } else { + const len = (pMax - pMin) * innerW; + const xStart = (-innerW/2) + (pMin * innerW); + const zPos = (-innerD/2) + (p.offset * innerD); + subtractHoles(len, p.height, xStart, thickness, zPos, 'x'); + } + }); } - // Обновляем нормали для корректного отображения света (убирает прозрачность) - finalBrush.geometry.computeVertexNormals(); - - return finalBrush.geometry; + return mainBrush.geometry; }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { From 5c06da8450166159d40e2995c9510ea9c77c8a1d 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: Mon, 12 Jan 2026 02:36:42 +0300 Subject: [PATCH 14/21] 5 --- package-lock.json | 2675 ----------------------------- package.json | 1 - src/services/geometryGenerator.ts | 372 ++-- 3 files changed, 230 insertions(+), 2818 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index a601a0e..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2675 +0,0 @@ -{ - "name": "printfit-organizer", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "printfit-organizer", - "version": "1.0.0", - "dependencies": { - "@react-three/drei": "^10.7.7", - "@react-three/fiber": "^9.4.2", - "jszip": "3.10.1", - "lucide-react": "^0.562.0", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "three": "^0.182.0", - "three-bvh-csg": "^0.0.17", - "three-stdlib": "^2.36.1", - "uuid": "^9.0.1" - }, - "devDependencies": { - "@types/node": "^22.14.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@types/uuid": "^9.0.8", - "@vitejs/plugin-react": "^5.0.0", - "typescript": "~5.8.2", - "vite": "^6.2.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@dimforge/rapier3d-compat": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", - "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", - "license": "Apache-2.0" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@mediapipe/tasks-vision": { - "version": "0.10.17", - "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", - "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", - "license": "Apache-2.0" - }, - "node_modules/@monogrid/gainmap-js": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", - "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", - "license": "MIT", - "dependencies": { - "promise-worker-transferable": "^1.0.4" - }, - "peerDependencies": { - "three": ">= 0.159.0" - } - }, - "node_modules/@react-three/drei": { - "version": "10.7.7", - "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", - "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mediapipe/tasks-vision": "0.10.17", - "@monogrid/gainmap-js": "^3.0.6", - "@use-gesture/react": "^10.3.1", - "camera-controls": "^3.1.0", - "cross-env": "^7.0.3", - "detect-gpu": "^5.0.56", - "glsl-noise": "^0.0.0", - "hls.js": "^1.5.17", - "maath": "^0.10.8", - "meshline": "^3.3.1", - "stats-gl": "^2.2.8", - "stats.js": "^0.17.0", - "suspend-react": "^0.1.3", - "three-mesh-bvh": "^0.8.3", - "three-stdlib": "^2.35.6", - "troika-three-text": "^0.52.4", - "tunnel-rat": "^0.1.2", - "use-sync-external-store": "^1.4.0", - "utility-types": "^3.11.0", - "zustand": "^5.0.1" - }, - "peerDependencies": { - "@react-three/fiber": "^9.0.0", - "react": "^19", - "react-dom": "^19", - "three": ">=0.159" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/@react-three/drei/node_modules/three-mesh-bvh": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", - "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", - "license": "MIT", - "peerDependencies": { - "three": ">= 0.159.0" - } - }, - "node_modules/@react-three/fiber": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", - "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.8", - "@types/webxr": "*", - "base64-js": "^1.5.1", - "buffer": "^6.0.3", - "its-fine": "^2.0.0", - "react-use-measure": "^2.1.7", - "scheduler": "^0.27.0", - "suspend-react": "^0.1.3", - "use-sync-external-store": "^1.4.0", - "zustand": "^5.0.3" - }, - "peerDependencies": { - "expo": ">=43.0", - "expo-asset": ">=8.4", - "expo-file-system": ">=11.0", - "expo-gl": ">=11.0", - "react": ">=19 <19.3", - "react-dom": ">=19 <19.3", - "react-native": ">=0.78", - "three": ">=0.156" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - }, - "expo-asset": { - "optional": true - }, - "expo-file-system": { - "optional": true - }, - "expo-gl": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tweenjs/tween.js": { - "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/draco3d": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", - "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", - "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/offscreencanvas": { - "version": "2019.7.3", - "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", - "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", - "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/react-reconciler": { - "version": "0.28.9", - "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", - "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/stats.js": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", - "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", - "license": "MIT" - }, - "node_modules/@types/three": { - "version": "0.182.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", - "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", - "license": "MIT", - "dependencies": { - "@dimforge/rapier3d-compat": "~0.12.0", - "@tweenjs/tween.js": "~23.1.3", - "@types/stats.js": "*", - "@types/webxr": ">=0.5.17", - "@webgpu/types": "*", - "fflate": "~0.8.2", - "meshoptimizer": "~0.22.0" - } - }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/webxr": { - "version": "0.5.24", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", - "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "license": "MIT" - }, - "node_modules/@use-gesture/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", - "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", - "license": "MIT" - }, - "node_modules/@use-gesture/react": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", - "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", - "license": "MIT", - "dependencies": { - "@use-gesture/core": "10.3.1" - }, - "peerDependencies": { - "react": ">= 16.8.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.5", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@webgpu/types": { - "version": "0.1.68", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.68.tgz", - "integrity": "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==", - "license": "BSD-3-Clause" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/camera-controls": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", - "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", - "license": "MIT", - "engines": { - "node": ">=22.0.0", - "npm": ">=10.5.1" - }, - "peerDependencies": { - "three": ">=0.126.1" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001764", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", - "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/detect-gpu": { - "version": "5.0.70", - "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", - "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", - "license": "MIT", - "dependencies": { - "webgl-constants": "^1.1.1" - } - }, - "node_modules/draco3d": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", - "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", - "license": "Apache-2.0" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glsl-noise": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", - "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", - "license": "MIT" - }, - "node_modules/hls.js": { - "version": "1.6.15", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", - "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", - "license": "Apache-2.0" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "license": "MIT" - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/its-fine": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", - "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", - "license": "MIT", - "dependencies": { - "@types/react-reconciler": "^0.28.9" - }, - "peerDependencies": { - "react": "^19.0.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.562.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", - "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/maath": { - "version": "0.10.8", - "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", - "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", - "license": "MIT", - "peerDependencies": { - "@types/three": ">=0.134.0", - "three": ">=0.134.0" - } - }, - "node_modules/meshline": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", - "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/meshoptimizer": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", - "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/potpack": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", - "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", - "license": "ISC" - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/promise-worker-transferable": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", - "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", - "license": "Apache-2.0", - "dependencies": { - "is-promise": "^2.1.0", - "lie": "^3.0.2" - } - }, - "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.3" - } - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-use-measure": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", - "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.13", - "react-dom": ">=16.13" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stats-gl": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", - "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", - "license": "MIT", - "dependencies": { - "@types/three": "*", - "three": "^0.170.0" - }, - "peerDependencies": { - "@types/three": "*", - "three": "*" - } - }, - "node_modules/stats-gl/node_modules/three": { - "version": "0.170.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", - "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", - "license": "MIT" - }, - "node_modules/stats.js": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", - "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/suspend-react": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", - "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", - "license": "MIT", - "peerDependencies": { - "react": ">=17.0" - } - }, - "node_modules/three": { - "version": "0.182.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", - "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT" - }, - "node_modules/three-bvh-csg": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/three-bvh-csg/-/three-bvh-csg-0.0.17.tgz", - "integrity": "sha512-iEkHDF8GRfGM6593Cuw8SnF1vfENCp46gIAtRzuL4nGXGWPcR1sbTBwM9ptDONb5twqlZp5WkAKya5aBKe2qcA==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.151.0", - "three-mesh-bvh": ">=0.6.6" - } - }, - "node_modules/three-mesh-bvh": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.5.tgz", - "integrity": "sha512-MYpwzUWDxPAKGhSBFin9E/7K4AAHyIm4IfMZQ/3+Z/jq/swa2dAhXx0yUNDd9mjlhLuzXkMBTGDZioL2GSlIfQ==", - "license": "MIT", - "peer": true, - "peerDependencies": { - "three": ">= 0.159.0" - } - }, - "node_modules/three-stdlib": { - "version": "2.36.1", - "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", - "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", - "license": "MIT", - "dependencies": { - "@types/draco3d": "^1.4.0", - "@types/offscreencanvas": "^2019.6.4", - "@types/webxr": "^0.5.2", - "draco3d": "^1.4.1", - "fflate": "^0.6.9", - "potpack": "^1.0.1" - }, - "peerDependencies": { - "three": ">=0.128.0" - } - }, - "node_modules/three-stdlib/node_modules/fflate": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", - "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/troika-three-text": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", - "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", - "license": "MIT", - "dependencies": { - "bidi-js": "^1.0.2", - "troika-three-utils": "^0.52.4", - "troika-worker-utils": "^0.52.0", - "webgl-sdf-generator": "1.1.1" - }, - "peerDependencies": { - "three": ">=0.125.0" - } - }, - "node_modules/troika-three-utils": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", - "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.125.0" - } - }, - "node_modules/troika-worker-utils": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", - "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", - "license": "MIT" - }, - "node_modules/tunnel-rat": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", - "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", - "license": "MIT", - "dependencies": { - "zustand": "^4.3.2" - } - }, - "node_modules/tunnel-rat/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utility-types": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", - "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/webgl-constants": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", - "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" - }, - "node_modules/webgl-sdf-generator": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", - "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/zustand": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", - "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json index 56fd87e..8709851 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "three": "^0.182.0", - "three-bvh-csg": "^0.0.17", "three-stdlib": "^2.36.1", "uuid": "^9.0.1" }, diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 1171577..cf1c737 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,21 +1,29 @@ import * as THREE from 'three'; -import { STLExporter } from 'three-stdlib'; -import { SUBTRACTION, ADDITION, Brush, Evaluator } from 'three-bvh-csg'; -import { mergeBufferGeometries } from 'three-stdlib'; +import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- +// --- УТИЛИТЫ --- +// Очистка координат (убирает фантомные ячейки) const cleanPoints = (points: number[]) => { - const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); - return [...new Set(rounded)]; + // Округляем и убираем дубликаты с допуском + const sorted = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); + const unique = [sorted[0]]; + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] - unique[unique.length - 1] > 0.002) { + unique.push(sorted[i]); + } + } + return unique; }; +// Сбор перегородок const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; return Object.values(splits.partitions).flat(); }; +// --- ВИЗУАЛИЗАЦИЯ (ЦВЕТНЫЕ БЛОКИ) --- export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; @@ -33,10 +41,12 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; - if (x2 - x1 < 0.001 || y2 - y1 < 0.001) continue; - const rawW = (x2 - x1) * config.drawer.width; const rawD = (y2 - y1) * config.drawer.depth; + + // Игнорируем слишком мелкие технические зазоры + if (rawW < 2 || rawD < 2) continue; + const rawX = x1 * config.drawer.width; const rawY = y1 * config.drawer.depth; @@ -47,7 +57,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat name: `Ячейка ${partCounter}`, width: Math.max(1, rawW - gap * 2), depth: Math.max(1, rawD - gap * 2), - height: config.drawer.height, + height: config.drawer.height - config.wallThickness, x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, @@ -59,7 +69,117 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (ПОСЛЕДОВАТЕЛЬНЫЙ CSG) --- +// --- ГЕНЕРАЦИЯ СТЕН С ПЕРФОРАЦИЕЙ (2D SHAPE -> EXTRUDE) --- + +const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { + const shape = new THREE.Shape(); + + // 1. Внешний контур (CCW - Против часовой стрелки) + shape.moveTo(0, 0); + shape.lineTo(length, 0); + shape.lineTo(length, height); + shape.lineTo(0, height); + shape.lineTo(0, 0); + + // Если перфорация выключена или стена слишком мала + if (!config.perforation?.enabled || length < 15 || height < 15) return shape; + + const { pattern, diameter, spacing } = config.perforation; + const step = diameter + Math.max(2, spacing); + const margin = 4; // Отступ от края + + // Рабочая зона + const effW = length - margin * 2; + const effH = height - margin * 2; + + if (effW <= diameter || effH <= diameter) return shape; + + // Расчет сетки + const rowH = pattern === 'circle' ? step : step * 0.866; + const cols = Math.floor(effW / step); + const rows = Math.floor(effH / rowH); + + // Центрирование + const startX = margin + (effW - (cols - 1) * step) / 2; + const startY = margin + (effH - (rows - 1) * rowH) / 2; + + for (let j = 0; j < rows; j++) { + const isOdd = j % 2 !== 0; + const cy = startY + j * rowH; + + for (let i = 0; i < cols; i++) { + let cx = startX + i * step; + if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; + + // Проверка границ + if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || + cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; + + const hole = new THREE.Path(); + const r = diameter / 2; + + // 2. ОТВЕРСТИЯ (CW - По часовой стрелке)!!! + // Это критически важно. Если рисовать CCW, Three.js зальет дырку. + + if (pattern === 'circle') { + // aClockwise = true + hole.absarc(cx, cy, r, 0, Math.PI * 2, true); + } + else if (pattern === 'hexagon') { + // 6 точек по часовой + for (let k = 0; k < 6; k++) { + const angle = (-k * 60 + 90) * Math.PI / 180; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); + } + hole.closePath(); + } + else if (pattern === 'triangle') { + const rot = isOdd ? 180 : 0; + // 3 точки по часовой + for (let k = 0; k < 3; k++) { + const angle = (-k * 120 + 90 + rot) * Math.PI / 180; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); + } + hole.closePath(); + } + shape.holes.push(hole); + } + } + return shape; +}; + +// Пол (всегда сплошной) +const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { + const shape = new THREE.Shape(); + const x = -width / 2; + const y = -depth / 2; + const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); + + if (r <= 0.1) { + shape.moveTo(x, y); + shape.lineTo(x + width, y); + shape.lineTo(x + width, y + depth); + shape.lineTo(x, y + depth); + shape.lineTo(x, y); + } else { + shape.moveTo(x, y + r); + shape.lineTo(x, y + depth - r); + shape.quadraticCurveTo(x, y + depth, x + r, y + depth); + shape.lineTo(x + width - r, y + depth); + shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; +}; + +// --- СБОРКА МОДЕЛИ --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, @@ -67,169 +187,137 @@ export const createBinGeometry = ( config?: AppConfig ): THREE.BufferGeometry => { + // Массив для слияния всех частей + const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - const evaluator = new Evaluator(); - evaluator.useGroups = false; + + // 1. ПОЛ + const floorShape = createFloorShape(width, depth, radius); + const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); + floorGeo.rotateX(-Math.PI / 2); // Кладем плашмя (XZ) + geometries.push(floorGeo); const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // 1. Базовая геометрия - ПОЛ - const floorGeo = new THREE.BoxGeometry(width, thickness, depth); - floorGeo.translate(0, thickness / 2, 0); - let mainBrush = new Brush(floorGeo); - mainBrush.updateMatrixWorld(); - - // --- ФУНКЦИИ ОПЕРАЦИЙ --- - - // Добавить твердое тело (стену/скругление) - const addSolid = (geo: THREE.BufferGeometry) => { - const brush = new Brush(geo); - brush.updateMatrixWorld(); - mainBrush = evaluator.evaluate(mainBrush, brush, ADDITION); - }; - - // Вычесть отверстия для конкретной зоны - const subtractHoles = (W: number, H: number, startX: number, startY: number, startZ: number, axis: 'x' | 'z') => { - if (!safeConfig.perforation?.enabled) return; - const { pattern, diameter, spacing } = safeConfig.perforation; - const margin = 4; - const step = diameter + Math.max(2, spacing); + // Функция для создания и установки стены + // Мы создаем 2D форму (Length x Height), экструдим её на Thickness, и ставим в 3D + const placeWall = (length: number, isVertical: boolean, centerX: number, centerZ: number) => { + // 1. Создаем 2D профиль с дырками + const shape = createPerforatedShape(length, wallH, safeConfig); - const cols = Math.floor((W - margin*2) / step); - const rowH = pattern === 'circle' ? step : step * 0.866; - const rows = Math.floor((H - margin*2) / rowH); + // 2. Экструдим (получаем толщину) + const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - if (cols <= 0 || rows <= 0) return; + // 3. Позиционируем + // Изначально: 0..Length по X, 0..Height по Y, 0..Thickness по Z + + // Центрируем геометрию относительно её осей для удобства вращения + geo.center(); + // Теперь она от -L/2 до L/2 по X, -H/2 до H/2 по Y, -T/2 до T/2 по Z - const offsetX = (W - cols * step) / 2; - const offsetY = (H - rows * rowH) / 2; - - // Базовое сверло - const drillBase = new THREE.CylinderGeometry(diameter/2, diameter/2, thickness * 3, 12); - if (axis === 'x') drillBase.rotateX(Math.PI / 2); // Вдоль Z - else drillBase.rotateZ(Math.PI / 2); // Вдоль X - - const drills: THREE.BufferGeometry[] = []; - - for(let r=0; r W - margin || v > H - margin) continue; - - const drill = drillBase.clone(); - if (axis === 'x') drill.translate(startX + u, startY + v, startZ); - else drill.translate(startX, startY + v, startZ + u); - drills.push(drill); - } + if (isVertical) { + // Вертикальная стена (идет вдоль Z) + geo.rotateY(Math.PI / 2); // Поворачиваем: теперь длина вдоль Z, толщина вдоль X } + + // Переносим на финальную позицию + // Y = thickness (пол) + wallH/2 (так как мы центрировали геометрию по Y) + geo.translate(centerX, thickness + wallH/2, centerZ); - if (drills.length > 0) { - const mergedDrills = mergeBufferGeometries(drills); - if (mergedDrills) { - const drillBrush = new Brush(mergedDrills); - drillBrush.updateMatrixWorld(); - mainBrush = evaluator.evaluate(mainBrush, drillBrush, SUBTRACTION); - } - } + geometries.push(geo); }; - - // 2. СБОРКА СТЕН (ADDITION) + // 2. ВНЕШНИЕ СТЕНЫ + // Front (Спереди, вдоль X) + placeWall(innerW, false, 0, depth/2 - thickness/2); - // Внешние стены - // Front - addSolid(new THREE.BoxGeometry(innerW, wallH, thickness).translate(0, thickness + wallH/2, depth/2 - thickness/2)); - // Back - addSolid(new THREE.BoxGeometry(innerW, wallH, thickness).translate(0, thickness + wallH/2, -depth/2 + thickness/2)); - // Left - addSolid(new THREE.BoxGeometry(thickness, wallH, depth).translate(-width/2 + thickness/2, thickness + wallH/2, 0)); - // Right - addSolid(new THREE.BoxGeometry(thickness, wallH, depth).translate(width/2 - thickness/2, thickness + wallH/2, 0)); + // Back (Сзади, вдоль X) + placeWall(innerW, false, 0, -depth/2 + thickness/2); + + // Left (Слева, вдоль Z, полная глубина) + placeWall(depth, true, -width/2 + thickness/2, 0); + + // Right (Справа, вдоль Z, полная глубина) + placeWall(depth, true, width/2 - thickness/2, 0); - // Внутренние перегородки + + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ let partitions: Partition[] = []; if (Array.isArray(splits)) partitions = splits; else if (splits && splits.partitions) partitions = getAllPartitions(splits); partitions.forEach(p => { - const pMin = p.min ?? 0; const pMax = p.max ?? 1; + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + if (Math.abs(pMax - pMin) < 0.001) return; - let w=0, h=p.height, d=0, x=0, z=0; - if (p.axis === 'x') { // Vert (Z-axis) - w = thickness; d = (pMax - pMin) * innerD; - x = (-innerW/2) + (p.offset * innerW); - z = (-innerD/2) + ((pMin + pMax)/2 * innerD); - } else { // Horiz (X-axis) - w = (pMax - pMin) * innerW; d = thickness; - x = (-innerW/2) + ((pMin + pMax)/2 * innerW); - z = (-innerD/2) + (p.offset * innerD); + let length = 0; + let cX = 0; + let cZ = 0; + let isVert = false; + + if (p.axis === 'x') { + // Вертикальная на схеме (вдоль Z) + isVert = true; + length = (pMax - pMin) * innerD; + // X: смещение от центра + cX = (-innerW/2) + (p.offset * innerW); + // Z: центр отрезка + const midRatio = (pMin + pMax) / 2; + cZ = (-innerD/2) + (midRatio * innerD); + } else { + // Горизонтальная на схеме (вдоль X) + isVert = false; + length = (pMax - pMin) * innerW; + // X: центр отрезка + const midRatio = (pMin + pMax) / 2; + cX = (-innerW/2) + (midRatio * innerW); + // Z: смещение от центра + cZ = (-innerD/2) + (p.offset * innerD); } - addSolid(new THREE.BoxGeometry(w, h, d).translate(x, thickness + h/2, z)); - }); - // 3. СКРУГЛЕНИЯ (ADDITION - цилиндры в углы) - if (radius > 0) { - const fRad = Math.min(radius, 5); - const filletBase = new THREE.CylinderGeometry(fRad, fRad, 1, 16); - filletBase.translate(0, 0.5, 0); // Pivot at bottom + placeWall(length, isVert, cX, cZ); - partitions.forEach(p => { - if (!p.rounded) return; - const pMin = p.min ?? 0; const pMax = p.max ?? 1; - if (Math.abs(pMax - pMin) < 0.001) return; + // --- СКРУГЛЕНИЯ (СТОЛБИКИ) --- + // Добавляем цилиндры в места стыков для прочности и визуального скругления + if (p.rounded && radius > 0) { + const r = Math.min(radius, 5); + const cylGeo = new THREE.CylinderGeometry(r, r, p.height, 16); + cylGeo.translate(0, p.height/2, 0); // Пивот внизу - const addF = (x: number, z: number) => { - const f = filletBase.clone(); - f.scale(1, p.height, 1); - f.translate(x, thickness, z); - addSolid(f); + const addCyl = (x: number, z: number) => { + const c = cylGeo.clone(); + c.translate(x, thickness, z); + geometries.push(c); }; - if (p.axis === 'x') { // Vert - const xPos = (-innerW/2) + (p.offset * innerW); - addF(xPos, (-innerD/2) + (pMin * innerD)); // Start Z - addF(xPos, (-innerD/2) + (pMax * innerD)); // End Z - } else { // Horiz - const zPos = (-innerD/2) + (p.offset * innerD); - addF((-innerW/2) + (pMin * innerW), zPos); // Start X - addF((-innerW/2) + (pMax * innerW), zPos); // End X - } - }); - } - - // 4. ПЕРФОРАЦИЯ (SUBTRACTION) - if (safeConfig.perforation?.enabled) { - // Внешние стены - subtractHoles(innerW, wallH, -innerW/2, thickness, depth/2, 'x'); // Front - subtractHoles(innerW, wallH, -innerW/2, thickness, -depth/2, 'x'); // Back - subtractHoles(depth, wallH, -width/2, thickness, -depth/2, 'z'); // Left - subtractHoles(depth, wallH, width/2, thickness, -depth/2, 'z'); // Right - - // Внутренние перегородки - partitions.forEach(p => { - const pMin = p.min ?? 0; const pMax = p.max ?? 1; - if (Math.abs(pMax - pMin) < 0.001) return; - if (p.axis === 'x') { - const len = (pMax - pMin) * innerD; - const xPos = (-innerW/2) + (p.offset * innerW); - const zStart = (-innerD/2) + (pMin * innerD); - subtractHoles(len, p.height, xPos, thickness, zStart, 'z'); + if (isVert) { + const startZ = cZ - length/2; + const endZ = cZ + length/2; + addCyl(cX, startZ); + addCyl(cX, endZ); } else { - const len = (pMax - pMin) * innerW; - const xStart = (-innerW/2) + (pMin * innerW); - const zPos = (-innerD/2) + (p.offset * innerD); - subtractHoles(len, p.height, xStart, thickness, zPos, 'x'); + const startX = cX - length/2; + const endX = cX + length/2; + addCyl(startX, cZ); + addCyl(endX, cZ); } - }); - } + } + }); - return mainBrush.geometry; + // 4. СЛИЯНИЕ ВСЕГО В ОДИН МЕШ + // Это критично для STL экспорта - должен быть один объект + const merged = mergeBufferGeometries(geometries); + + if (merged) { + merged.computeVertexNormals(); + return merged; + } + + return new THREE.BoxGeometry(1, 1, 1); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { From 6d40bc851d32192c0121f443c9fe8097dad16c63 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: Mon, 12 Jan 2026 02:52:53 +0300 Subject: [PATCH 15/21] 6 --- src/services/geometryGenerator.ts | 371 +++++++++++++----------------- 1 file changed, 156 insertions(+), 215 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index cf1c737..59087f0 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,29 +1,26 @@ import * as THREE from 'three'; -import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; +import { STLExporter } from 'three-stdlib'; +import { SUBTRACTION, Brush, Evaluator } from 'three-bvh-csg'; +import { mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -// --- УТИЛИТЫ --- +// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// Очистка координат (убирает фантомные ячейки) +// Очистка координат с округлением, чтобы убрать "фантомные" микро-ячейки const cleanPoints = (points: number[]) => { - // Округляем и убираем дубликаты с допуском - const sorted = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); - const unique = [sorted[0]]; - for (let i = 1; i < sorted.length; i++) { - if (sorted[i] - unique[unique.length - 1] > 0.002) { - unique.push(sorted[i]); - } - } - return unique; + // Округляем до 2 знака (сантиметры/миллиметры), чтобы убрать дрожание float + const rounded = points.map(p => parseFloat(p.toFixed(3))).sort((a, b) => a - b); + // Убираем дубликаты + return [...new Set(rounded)]; }; -// Сбор перегородок +// Получение плоского списка всех перегородок из объекта const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; return Object.values(splits.partitions).flat(); }; -// --- ВИЗУАЛИЗАЦИЯ (ЦВЕТНЫЕ БЛОКИ) --- +// Функция для визуализации (шаг 3 - цветные блоки) export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; @@ -41,10 +38,9 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; + // Фильтр: если ячейка меньше 2мм - это мусор const rawW = (x2 - x1) * config.drawer.width; const rawD = (y2 - y1) * config.drawer.depth; - - // Игнорируем слишком мелкие технические зазоры if (rawW < 2 || rawD < 2) continue; const rawX = x1 * config.drawer.width; @@ -57,7 +53,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat name: `Ячейка ${partCounter}`, width: Math.max(1, rawW - gap * 2), depth: Math.max(1, rawD - gap * 2), - height: config.drawer.height - config.wallThickness, + height: config.drawer.height, x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, @@ -69,117 +65,7 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ СТЕН С ПЕРФОРАЦИЕЙ (2D SHAPE -> EXTRUDE) --- - -const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { - const shape = new THREE.Shape(); - - // 1. Внешний контур (CCW - Против часовой стрелки) - shape.moveTo(0, 0); - shape.lineTo(length, 0); - shape.lineTo(length, height); - shape.lineTo(0, height); - shape.lineTo(0, 0); - - // Если перфорация выключена или стена слишком мала - if (!config.perforation?.enabled || length < 15 || height < 15) return shape; - - const { pattern, diameter, spacing } = config.perforation; - const step = diameter + Math.max(2, spacing); - const margin = 4; // Отступ от края - - // Рабочая зона - const effW = length - margin * 2; - const effH = height - margin * 2; - - if (effW <= diameter || effH <= diameter) return shape; - - // Расчет сетки - const rowH = pattern === 'circle' ? step : step * 0.866; - const cols = Math.floor(effW / step); - const rows = Math.floor(effH / rowH); - - // Центрирование - const startX = margin + (effW - (cols - 1) * step) / 2; - const startY = margin + (effH - (rows - 1) * rowH) / 2; - - for (let j = 0; j < rows; j++) { - const isOdd = j % 2 !== 0; - const cy = startY + j * rowH; - - for (let i = 0; i < cols; i++) { - let cx = startX + i * step; - if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; - - // Проверка границ - if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || - cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; - - const hole = new THREE.Path(); - const r = diameter / 2; - - // 2. ОТВЕРСТИЯ (CW - По часовой стрелке)!!! - // Это критически важно. Если рисовать CCW, Three.js зальет дырку. - - if (pattern === 'circle') { - // aClockwise = true - hole.absarc(cx, cy, r, 0, Math.PI * 2, true); - } - else if (pattern === 'hexagon') { - // 6 точек по часовой - for (let k = 0; k < 6; k++) { - const angle = (-k * 60 + 90) * Math.PI / 180; - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); - } - hole.closePath(); - } - else if (pattern === 'triangle') { - const rot = isOdd ? 180 : 0; - // 3 точки по часовой - for (let k = 0; k < 3; k++) { - const angle = (-k * 120 + 90 + rot) * Math.PI / 180; - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); - } - hole.closePath(); - } - shape.holes.push(hole); - } - } - return shape; -}; - -// Пол (всегда сплошной) -const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - const x = -width / 2; - const y = -depth / 2; - const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); - - if (r <= 0.1) { - shape.moveTo(x, y); - shape.lineTo(x + width, y); - shape.lineTo(x + width, y + depth); - shape.lineTo(x, y + depth); - shape.lineTo(x, y); - } else { - shape.moveTo(x, y + r); - shape.lineTo(x, y + depth - r); - shape.quadraticCurveTo(x, y + depth, x + r, y + depth); - shape.lineTo(x + width - r, y + depth); - shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; -}; - -// --- СБОРКА МОДЕЛИ --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (АТОМАРНЫЙ CSG) --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, @@ -187,63 +73,117 @@ export const createBinGeometry = ( config?: AppConfig ): THREE.BufferGeometry => { - // Массив для слияния всех частей - const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; + const evaluator = new Evaluator(); + // Отключаем группы, чтобы результат был единым мешем с одним материалом + evaluator.useGroups = false; - // 1. ПОЛ - const floorShape = createFloorShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // Кладем плашмя (XZ) - geometries.push(floorGeo); + const finalGeometries: THREE.BufferGeometry[] = []; + + // 1. ПОЛ (Всегда сплошной, без дырок) + const floorGeo = new THREE.BoxGeometry(width, thickness, depth); + floorGeo.translate(0, thickness / 2, 0); // Поднимаем, чтобы низ был на 0 + finalGeometries.push(floorGeo); + + // --- ФУНКЦИЯ СОЗДАНИЯ "УМНОЙ" СТЕНКИ --- + // Создает стену в локальных координатах, сверлит её, а потом ставит на место + const createSmartWall = (wallLength: number, wallHeight: number, x: number, z: number, isVertical: boolean) => { + + // 1. Создаем "заготовку" стены в центре координат (лежащую вдоль X) + // Размеры: Длина=wallLength, Высота=wallHeight, Толщина=thickness + const wallGeometry = new THREE.BoxGeometry(wallLength, wallHeight, thickness); + + // Сразу создаем Brush для CSG + let wallBrush = new Brush(wallGeometry); + wallBrush.updateMatrixWorld(); + + // 2. Сверлим дырки (если включено) + if (safeConfig.perforation?.enabled) { + const { pattern, diameter, spacing } = safeConfig.perforation; + const margin = 4; // Отступ от краев + const step = diameter + Math.max(2, spacing); + + // Рассчитываем сетку + const cols = Math.floor((wallLength - margin * 2) / step); + // Для сот (hexagon) шаг по вертикали меньше + const rowH = pattern === 'circle' ? step : step * 0.866; + const rows = Math.floor((wallHeight - margin * 2) / rowH); + + if (cols > 0 && rows > 0) { + const startX = -wallLength / 2 + (wallLength - cols * step) / 2 + diameter / 2; + const startY = -wallHeight / 2 + (wallHeight - rows * rowH) / 2 + diameter / 2; + + // Создаем один шаблон "сверла" + const drillGeo = new THREE.CylinderGeometry(diameter / 2, diameter / 2, thickness * 2, 12); + drillGeo.rotateX(Math.PI / 2); // Поворачиваем, чтобы сверлил сквозь стену (по оси Z локально) + + // Собираем все сверла в одну геометрию (merge), чтобы вычесть 1 раз + const drills: THREE.BufferGeometry[] = []; + + for (let r = 0; r < rows; r++) { + const isOdd = r % 2 !== 0; + for (let c = 0; c < cols; c++) { + let cx = startX + c * step; + let cy = startY + r * rowH; + + // Смещение для сот/треугольников + if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) { + cx += step / 2; + } + + // Проверка границ, чтобы не сверлить воздух или край + if (cx > wallLength / 2 - margin || cx < -wallLength / 2 + margin) continue; + + const drill = drillGeo.clone(); + drill.translate(cx, cy, 0); + drills.push(drill); + } + } + + if (drills.length > 0) { + const mergedDrills = mergeBufferGeometries(drills); + if (mergedDrills) { + const drillBrush = new Brush(mergedDrills); + drillBrush.updateMatrixWorld(); + // САМОЕ ГЛАВНОЕ: Вычитаем сверла из стены + const result = evaluator.evaluate(wallBrush, drillBrush, SUBTRACTION); + wallBrush = result; // Обновляем стену + } + } + } + } + + // 3. Позиционируем готовую (просверленную) стену в мире + // Сейчас стена в центре (0,0,0) и смотрит вдоль X + const resultGeo = wallBrush.geometry; + + if (isVertical) { + // Если стена вертикальная (вдоль Z), поворачиваем на 90 градусов вокруг Y + resultGeo.rotateY(Math.PI / 2); + } + + // Перемещаем на финальную позицию + // Y = thickness (пол) + wallHeight/2 (центр стены) + resultGeo.translate(x, thickness + wallHeight / 2, z); + + return resultGeo; + }; const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // Функция для создания и установки стены - // Мы создаем 2D форму (Length x Height), экструдим её на Thickness, и ставим в 3D - const placeWall = (length: number, isVertical: boolean, centerX: number, centerZ: number) => { - // 1. Создаем 2D профиль с дырками - const shape = createPerforatedShape(length, wallH, safeConfig); - - // 2. Экструдим (получаем толщину) - const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - - // 3. Позиционируем - // Изначально: 0..Length по X, 0..Height по Y, 0..Thickness по Z - - // Центрируем геометрию относительно её осей для удобства вращения - geo.center(); - // Теперь она от -L/2 до L/2 по X, -H/2 до H/2 по Y, -T/2 до T/2 по Z - - if (isVertical) { - // Вертикальная стена (идет вдоль Z) - geo.rotateY(Math.PI / 2); // Поворачиваем: теперь длина вдоль Z, толщина вдоль X - } - - // Переносим на финальную позицию - // Y = thickness (пол) + wallH/2 (так как мы центрировали геометрию по Y) - geo.translate(centerX, thickness + wallH/2, centerZ); - - geometries.push(geo); - }; - - // 2. ВНЕШНИЕ СТЕНЫ + // 2. СОЗДАЕМ ВНЕШНИЕ СТЕНЫ // Front (Спереди, вдоль X) - placeWall(innerW, false, 0, depth/2 - thickness/2); - + finalGeometries.push(createSmartWall(innerW, wallH, 0, depth / 2 - thickness / 2, false)); // Back (Сзади, вдоль X) - placeWall(innerW, false, 0, -depth/2 + thickness/2); - - // Left (Слева, вдоль Z, полная глубина) - placeWall(depth, true, -width/2 + thickness/2, 0); - - // Right (Справа, вдоль Z, полная глубина) - placeWall(depth, true, width/2 - thickness/2, 0); + finalGeometries.push(createSmartWall(innerW, wallH, 0, -depth / 2 + thickness / 2, false)); + // Left (Слева, вдоль Z) - полная глубина + finalGeometries.push(createSmartWall(depth, wallH, -width / 2 + thickness / 2, 0, true)); + // Right (Справа, вдоль Z) - полная глубина + finalGeometries.push(createSmartWall(depth, wallH, width / 2 - thickness / 2, 0, true)); - - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ + // 3. СОЗДАЕМ ВНУТРЕННИЕ ПЕРЕГОРОДКИ let partitions: Partition[] = []; if (Array.isArray(splits)) partitions = splits; else if (splits && splits.partitions) partitions = getAllPartitions(splits); @@ -252,71 +192,72 @@ export const createBinGeometry = ( const pMin = p.min ?? 0; const pMax = p.max ?? 1; + // Защита от нулевых длин if (Math.abs(pMax - pMin) < 0.001) return; - let length = 0; - let cX = 0; - let cZ = 0; + let len = 0; + let xPos = 0; + let zPos = 0; let isVert = false; - if (p.axis === 'x') { - // Вертикальная на схеме (вдоль Z) + if (p.axis === 'x') { + // Вертикальная перегородка (Вдоль Z) isVert = true; - length = (pMax - pMin) * innerD; + len = (pMax - pMin) * innerD; // X: смещение от центра - cX = (-innerW/2) + (p.offset * innerW); + xPos = (-innerW / 2) + (p.offset * innerW); // Z: центр отрезка - const midRatio = (pMin + pMax) / 2; - cZ = (-innerD/2) + (midRatio * innerD); - } else { - // Горизонтальная на схеме (вдоль X) + const midZRatio = (pMin + pMax) / 2; + zPos = (-innerD / 2) + (midZRatio * innerD); + } else { + // Горизонтальная перегородка (Вдоль X) isVert = false; - length = (pMax - pMin) * innerW; + len = (pMax - pMin) * innerW; // X: центр отрезка - const midRatio = (pMin + pMax) / 2; - cX = (-innerW/2) + (midRatio * innerW); + const midXRatio = (pMin + pMax) / 2; + xPos = (-innerW / 2) + (midXRatio * innerW); // Z: смещение от центра - cZ = (-innerD/2) + (p.offset * innerD); + zPos = (-innerD / 2) + (p.offset * innerD); } - placeWall(length, isVert, cX, cZ); + // Генерируем, сверлим и ставим перегородку + const partGeo = createSmartWall(len, p.height, xPos, zPos, isVert); + finalGeometries.push(partGeo); // --- СКРУГЛЕНИЯ (СТОЛБИКИ) --- - // Добавляем цилиндры в места стыков для прочности и визуального скругления + // Добавляем цилиндры в торцы, если включено скругление if (p.rounded && radius > 0) { const r = Math.min(radius, 5); - const cylGeo = new THREE.CylinderGeometry(r, r, p.height, 16); - cylGeo.translate(0, p.height/2, 0); // Пивот внизу - - const addCyl = (x: number, z: number) => { - const c = cylGeo.clone(); - c.translate(x, thickness, z); - geometries.push(c); - }; + const filletGeo = new THREE.CylinderGeometry(r, r, p.height, 12); + filletGeo.translate(0, p.height / 2 + thickness, 0); // Ставим на пол + // Определяем координаты концов стенки if (isVert) { - const startZ = cZ - length/2; - const endZ = cZ + length/2; - addCyl(cX, startZ); - addCyl(cX, endZ); + const zStart = (-innerD / 2) + (pMin * innerD); + const zEnd = (-innerD / 2) + (pMax * innerD); + + const f1 = filletGeo.clone(); f1.translate(xPos, 0, zStart); finalGeometries.push(f1); + const f2 = filletGeo.clone(); f2.translate(xPos, 0, zEnd); finalGeometries.push(f2); } else { - const startX = cX - length/2; - const endX = cX + length/2; - addCyl(startX, cZ); - addCyl(endX, cZ); + const xStart = (-innerW / 2) + (pMin * innerW); + const xEnd = (-innerW / 2) + (pMax * innerW); + + const f1 = filletGeo.clone(); f1.translate(xStart, 0, zPos); finalGeometries.push(f1); + const f2 = filletGeo.clone(); f2.translate(xEnd, 0, zPos); finalGeometries.push(f2); } } }); - // 4. СЛИЯНИЕ ВСЕГО В ОДИН МЕШ - // Это критично для STL экспорта - должен быть один объект - const merged = mergeBufferGeometries(geometries); + // 4. СЛИЯНИЕ ВСЕГО В ОДИН MESH + // Простое слияние геометрий (без CSG Union, так как детали просто соприкасаются) + // Это намного быстрее и не вызывает артефактов + const finalMerged = mergeBufferGeometries(finalGeometries); - if (merged) { - merged.computeVertexNormals(); - return merged; + if (finalMerged) { + finalMerged.computeVertexNormals(); + return finalMerged; } - + return new THREE.BoxGeometry(1, 1, 1); }; From d8d34371a9c8c6839f5dc52e45f24aa7a6eee243 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: Mon, 12 Jan 2026 02:53:45 +0300 Subject: [PATCH 16/21] 7 --- src/services/geometryGenerator.ts | 303 ++++++++++++++---------------- 1 file changed, 144 insertions(+), 159 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 59087f0..a8f228f 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,26 +1,20 @@ import * as THREE from 'three'; -import { STLExporter } from 'three-stdlib'; -import { SUBTRACTION, Brush, Evaluator } from 'three-bvh-csg'; -import { mergeBufferGeometries } from 'three-stdlib'; +import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- +// --- УТИЛИТЫ --- -// Очистка координат с округлением, чтобы убрать "фантомные" микро-ячейки const cleanPoints = (points: number[]) => { - // Округляем до 2 знака (сантиметры/миллиметры), чтобы убрать дрожание float - const rounded = points.map(p => parseFloat(p.toFixed(3))).sort((a, b) => a - b); - // Убираем дубликаты + const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); return [...new Set(rounded)]; }; -// Получение плоского списка всех перегородок из объекта const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; return Object.values(splits.partitions).flat(); }; -// Функция для визуализации (шаг 3 - цветные блоки) +// Функция для визуализации (шаг 3) export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; @@ -38,14 +32,13 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const y1 = uniqueY[j]; const y2 = uniqueY[j+1]; - // Фильтр: если ячейка меньше 2мм - это мусор const rawW = (x2 - x1) * config.drawer.width; const rawD = (y2 - y1) * config.drawer.depth; + if (rawW < 2 || rawD < 2) continue; const rawX = x1 * config.drawer.width; const rawY = y1 * config.drawer.depth; - const gap = config.wallThickness / 2 + 0.1; parts.push({ @@ -65,7 +58,81 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (АТОМАРНЫЙ CSG) --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (НАТИВНЫЙ THREE.JS) --- + +// Создает 2D форму стены с отверстиями +const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { + const shape = new THREE.Shape(); + + // 1. Внешний контур (CCW - Против часовой стрелки) + shape.moveTo(0, 0); + shape.lineTo(length, 0); + shape.lineTo(length, height); + shape.lineTo(0, height); + shape.lineTo(0, 0); + + // Если перфорация выключена + if (!config.perforation?.enabled || length < 10 || height < 10) return shape; + + const { pattern, diameter, spacing } = config.perforation; + const step = diameter + Math.max(2, spacing); + const margin = 4; + + const effW = length - margin * 2; + const effH = height - margin * 2; + + if (effW <= diameter || effH <= diameter) return shape; + + const rowH = pattern === 'circle' ? step : step * 0.866; + const cols = Math.floor(effW / step); + const rows = Math.floor(effH / rowH); + + const startX = margin + (effW - (cols - 1) * step) / 2; + const startY = margin + (effH - (rows - 1) * rowH) / 2; + + for (let j = 0; j < rows; j++) { + const isOdd = j % 2 !== 0; + const cy = startY + j * rowH; + + for (let i = 0; i < cols; i++) { + let cx = startX + i * step; + if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; + + if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || + cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; + + const hole = new THREE.Path(); + const r = diameter / 2; + + // 2. Отверстия (CW - По часовой стрелке). + // aClockwise = true. Это критично для корректного вырезания. + if (pattern === 'circle') { + hole.absarc(cx, cy, r, 0, Math.PI * 2, true); + } + else if (pattern === 'hexagon') { + for (let k = 0; k < 6; k++) { + const angle = (-k * 60 + 90) * Math.PI / 180; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); + } + hole.closePath(); + } + else if (pattern === 'triangle') { + const rot = isOdd ? 180 : 0; + for (let k = 0; k < 3; k++) { + const angle = (-k * 120 + 90 + rot) * Math.PI / 180; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); + } + hole.closePath(); + } + shape.holes.push(hole); + } + } + return shape; +}; export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, @@ -73,117 +140,49 @@ export const createBinGeometry = ( config?: AppConfig ): THREE.BufferGeometry => { + const geometries: THREE.BufferGeometry[] = []; const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - const evaluator = new Evaluator(); - // Отключаем группы, чтобы результат был единым мешем с одним материалом - evaluator.useGroups = false; - const finalGeometries: THREE.BufferGeometry[] = []; - - // 1. ПОЛ (Всегда сплошной, без дырок) + // 1. ПОЛ const floorGeo = new THREE.BoxGeometry(width, thickness, depth); - floorGeo.translate(0, thickness / 2, 0); // Поднимаем, чтобы низ был на 0 - finalGeometries.push(floorGeo); - - // --- ФУНКЦИЯ СОЗДАНИЯ "УМНОЙ" СТЕНКИ --- - // Создает стену в локальных координатах, сверлит её, а потом ставит на место - const createSmartWall = (wallLength: number, wallHeight: number, x: number, z: number, isVertical: boolean) => { - - // 1. Создаем "заготовку" стены в центре координат (лежащую вдоль X) - // Размеры: Длина=wallLength, Высота=wallHeight, Толщина=thickness - const wallGeometry = new THREE.BoxGeometry(wallLength, wallHeight, thickness); - - // Сразу создаем Brush для CSG - let wallBrush = new Brush(wallGeometry); - wallBrush.updateMatrixWorld(); - - // 2. Сверлим дырки (если включено) - if (safeConfig.perforation?.enabled) { - const { pattern, diameter, spacing } = safeConfig.perforation; - const margin = 4; // Отступ от краев - const step = diameter + Math.max(2, spacing); - - // Рассчитываем сетку - const cols = Math.floor((wallLength - margin * 2) / step); - // Для сот (hexagon) шаг по вертикали меньше - const rowH = pattern === 'circle' ? step : step * 0.866; - const rows = Math.floor((wallHeight - margin * 2) / rowH); - - if (cols > 0 && rows > 0) { - const startX = -wallLength / 2 + (wallLength - cols * step) / 2 + diameter / 2; - const startY = -wallHeight / 2 + (wallHeight - rows * rowH) / 2 + diameter / 2; - - // Создаем один шаблон "сверла" - const drillGeo = new THREE.CylinderGeometry(diameter / 2, diameter / 2, thickness * 2, 12); - drillGeo.rotateX(Math.PI / 2); // Поворачиваем, чтобы сверлил сквозь стену (по оси Z локально) - - // Собираем все сверла в одну геометрию (merge), чтобы вычесть 1 раз - const drills: THREE.BufferGeometry[] = []; - - for (let r = 0; r < rows; r++) { - const isOdd = r % 2 !== 0; - for (let c = 0; c < cols; c++) { - let cx = startX + c * step; - let cy = startY + r * rowH; - - // Смещение для сот/треугольников - if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) { - cx += step / 2; - } - - // Проверка границ, чтобы не сверлить воздух или край - if (cx > wallLength / 2 - margin || cx < -wallLength / 2 + margin) continue; - - const drill = drillGeo.clone(); - drill.translate(cx, cy, 0); - drills.push(drill); - } - } - - if (drills.length > 0) { - const mergedDrills = mergeBufferGeometries(drills); - if (mergedDrills) { - const drillBrush = new Brush(mergedDrills); - drillBrush.updateMatrixWorld(); - // САМОЕ ГЛАВНОЕ: Вычитаем сверла из стены - const result = evaluator.evaluate(wallBrush, drillBrush, SUBTRACTION); - wallBrush = result; // Обновляем стену - } - } - } - } - - // 3. Позиционируем готовую (просверленную) стену в мире - // Сейчас стена в центре (0,0,0) и смотрит вдоль X - const resultGeo = wallBrush.geometry; - - if (isVertical) { - // Если стена вертикальная (вдоль Z), поворачиваем на 90 градусов вокруг Y - resultGeo.rotateY(Math.PI / 2); - } - - // Перемещаем на финальную позицию - // Y = thickness (пол) + wallHeight/2 (центр стены) - resultGeo.translate(x, thickness + wallHeight / 2, z); - - return resultGeo; - }; + floorGeo.translate(0, thickness / 2, 0); + geometries.push(floorGeo); const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // 2. СОЗДАЕМ ВНЕШНИЕ СТЕНЫ - // Front (Спереди, вдоль X) - finalGeometries.push(createSmartWall(innerW, wallH, 0, depth / 2 - thickness / 2, false)); - // Back (Сзади, вдоль X) - finalGeometries.push(createSmartWall(innerW, wallH, 0, -depth / 2 + thickness / 2, false)); - // Left (Слева, вдоль Z) - полная глубина - finalGeometries.push(createSmartWall(depth, wallH, -width / 2 + thickness / 2, 0, true)); - // Right (Справа, вдоль Z) - полная глубина - finalGeometries.push(createSmartWall(depth, wallH, width / 2 - thickness / 2, 0, true)); + // Хелпер: создает стену, экструдит, вращает и ставит на место + const addWall = (len: number, h: number, x: number, z: number, isVert: boolean) => { + // 1. 2D форма + const shape = createPerforatedShape(len, h, safeConfig); + // 2. Экструзия (Толщина) + const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); + + // 3. Центрирование геометрии (чтобы вращать вокруг центра) + geo.center(); - // 3. СОЗДАЕМ ВНУТРЕННИЕ ПЕРЕГОРОДКИ + // 4. Поворот и Позиционирование + if (isVert) { + geo.rotateY(Math.PI / 2); + } + // Поднимаем на пол (thickness + h/2) + geo.translate(x, thickness + h/2, z); + + geometries.push(geo); + }; + + // 2. ВНЕШНИЕ СТЕНЫ + // Front (вдоль X) + addWall(innerW, wallH, 0, depth/2 - thickness/2, false); + // Back (вдоль X) + addWall(innerW, wallH, 0, -depth/2 + thickness/2, false); + // Left (вдоль Z) + addWall(depth, wallH, -width/2 + thickness/2, 0, true); + // Right (вдоль Z) + addWall(depth, wallH, width/2 - thickness/2, 0, true); + + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ let partitions: Partition[] = []; if (Array.isArray(splits)) partitions = splits; else if (splits && splits.partitions) partitions = getAllPartitions(splits); @@ -191,8 +190,6 @@ export const createBinGeometry = ( partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; - - // Защита от нулевых длин if (Math.abs(pMax - pMin) < 0.001) return; let len = 0; @@ -200,65 +197,53 @@ export const createBinGeometry = ( let zPos = 0; let isVert = false; - if (p.axis === 'x') { - // Вертикальная перегородка (Вдоль Z) + if (p.axis === 'x') { // Vert (Z-axis) isVert = true; len = (pMax - pMin) * innerD; - // X: смещение от центра - xPos = (-innerW / 2) + (p.offset * innerW); - // Z: центр отрезка - const midZRatio = (pMin + pMax) / 2; - zPos = (-innerD / 2) + (midZRatio * innerD); - } else { - // Горизонтальная перегородка (Вдоль X) + xPos = (-innerW/2) + (p.offset * innerW); + const midZ = (pMin + pMax) / 2; + zPos = (-innerD/2) + (midZ * innerD); + } else { // Horiz (X-axis) isVert = false; len = (pMax - pMin) * innerW; - // X: центр отрезка - const midXRatio = (pMin + pMax) / 2; - xPos = (-innerW / 2) + (midXRatio * innerW); - // Z: смещение от центра - zPos = (-innerD / 2) + (p.offset * innerD); + const midX = (pMin + pMax) / 2; + xPos = (-innerW/2) + (midX * innerW); + zPos = (-innerD/2) + (p.offset * innerD); } - // Генерируем, сверлим и ставим перегородку - const partGeo = createSmartWall(len, p.height, xPos, zPos, isVert); - finalGeometries.push(partGeo); + addWall(len, p.height, xPos, zPos, isVert); // --- СКРУГЛЕНИЯ (СТОЛБИКИ) --- - // Добавляем цилиндры в торцы, если включено скругление if (p.rounded && radius > 0) { const r = Math.min(radius, 5); - const filletGeo = new THREE.CylinderGeometry(r, r, p.height, 12); - filletGeo.translate(0, p.height / 2 + thickness, 0); // Ставим на пол + const cylGeo = new THREE.CylinderGeometry(r, r, p.height, 12); + // Поднимаем пивот в центр (так как addWall центрирует) + // Или просто позиционируем как есть + + const addFillet = (fx: number, fz: number) => { + const c = cylGeo.clone(); + c.translate(fx, thickness + p.height/2, fz); + geometries.push(c); + }; - // Определяем координаты концов стенки if (isVert) { - const zStart = (-innerD / 2) + (pMin * innerD); - const zEnd = (-innerD / 2) + (pMax * innerD); - - const f1 = filletGeo.clone(); f1.translate(xPos, 0, zStart); finalGeometries.push(f1); - const f2 = filletGeo.clone(); f2.translate(xPos, 0, zEnd); finalGeometries.push(f2); + const zStart = zPos - len/2; + const zEnd = zPos + len/2; + addFillet(xPos, zStart); + addFillet(xPos, zEnd); } else { - const xStart = (-innerW / 2) + (pMin * innerW); - const xEnd = (-innerW / 2) + (pMax * innerW); - - const f1 = filletGeo.clone(); f1.translate(xStart, 0, zPos); finalGeometries.push(f1); - const f2 = filletGeo.clone(); f2.translate(xEnd, 0, zPos); finalGeometries.push(f2); + const xStart = xPos - len/2; + const xEnd = xPos + len/2; + addFillet(xStart, zPos); + addFillet(xEnd, zPos); } } }); - // 4. СЛИЯНИЕ ВСЕГО В ОДИН MESH - // Простое слияние геометрий (без CSG Union, так как детали просто соприкасаются) - // Это намного быстрее и не вызывает артефактов - const finalMerged = mergeBufferGeometries(finalGeometries); - - if (finalMerged) { - finalMerged.computeVertexNormals(); - return finalMerged; - } - - return new THREE.BoxGeometry(1, 1, 1); + // 4. СЛИЯНИЕ + const merged = mergeBufferGeometries(geometries); + if (merged) merged.computeVertexNormals(); + return merged || new THREE.BoxGeometry(1, 1, 1); }; export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { From 5235fa888f9322c95a507c5c3ac9e123852f1418 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: Mon, 12 Jan 2026 02:58:41 +0300 Subject: [PATCH 17/21] 8 --- src/services/geometryGenerator.ts | 148 ++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 39 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index a8f228f..314a610 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -2,19 +2,21 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; -// --- УТИЛИТЫ --- +// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- +// Очистка координат (убирает фантомные ячейки и дрожание) const cleanPoints = (points: number[]) => { const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); return [...new Set(rounded)]; }; +// Сбор всех перегородок в плоский список const getAllPartitions = (splits: LayoutSplits): Partition[] => { if (!splits || !splits.partitions) return []; return Object.values(splits.partitions).flat(); }; -// Функция для визуализации (шаг 3) +// --- ВИЗУАЛИЗАЦИЯ (Цветные кубики для шага 3) --- export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; @@ -35,18 +37,21 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const rawW = (x2 - x1) * config.drawer.width; const rawD = (y2 - y1) * config.drawer.depth; + // Фильтр слишком мелких ячеек if (rawW < 2 || rawD < 2) continue; const rawX = x1 * config.drawer.width; const rawY = y1 * config.drawer.depth; - const gap = config.wallThickness / 2 + 0.1; + + // Зазор для визуализации (чтобы блоки не слипались) + const gap = config.wallThickness / 2 + 0.15; parts.push({ id: `part-${partCounter}`, name: `Ячейка ${partCounter}`, width: Math.max(1, rawW - gap * 2), depth: Math.max(1, rawD - gap * 2), - height: config.drawer.height, + height: config.drawer.height - config.wallThickness, // Высота без пола x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, @@ -58,35 +63,39 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (НАТИВНЫЙ THREE.JS) --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (NATIVE THREE.JS) --- -// Создает 2D форму стены с отверстиями +// Функция создания 2D формы с дырками const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); - // 1. Внешний контур (CCW - Против часовой стрелки) + // 1. Внешний контур: ПРОТИВ ЧАСОВОЙ (CCW) + // (0,0) -> (L,0) -> (L,H) -> (0,H) -> (0,0) shape.moveTo(0, 0); shape.lineTo(length, 0); shape.lineTo(length, height); shape.lineTo(0, height); shape.lineTo(0, 0); - // Если перфорация выключена - if (!config.perforation?.enabled || length < 10 || height < 10) return shape; + // Если перфорация выключена или стенка слишком маленькая для дырок + if (!config.perforation?.enabled || length < 15 || height < 15) return shape; const { pattern, diameter, spacing } = config.perforation; const step = diameter + Math.max(2, spacing); - const margin = 4; + const margin = 4; // Отступ от краев стенки + // Эффективная зона для отверстий const effW = length - margin * 2; const effH = height - margin * 2; if (effW <= diameter || effH <= diameter) return shape; + // Расчет сетки const rowH = pattern === 'circle' ? step : step * 0.866; const cols = Math.floor(effW / step); const rows = Math.floor(effH / rowH); + // Центрирование сетки const startX = margin + (effW - (cols - 1) * step) / 2; const startY = margin + (effH - (rows - 1) * rowH) / 2; @@ -96,21 +105,25 @@ const createPerforatedShape = (length: number, height: number, config: AppConfig for (let i = 0; i < cols; i++) { let cx = startX + i * step; + // Смещение для сот/треугольников if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; + // Проверка, что отверстие не вылезает за пределы if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; const hole = new THREE.Path(); const r = diameter / 2; - // 2. Отверстия (CW - По часовой стрелке). - // aClockwise = true. Это критично для корректного вырезания. + // 2. ВНУТРЕННИЕ ОТВЕРСТИЯ: ПО ЧАСОВОЙ (CW) + // Параметр aClockwise = true в absarc. Это критично! + if (pattern === 'circle') { hole.absarc(cx, cy, r, 0, Math.PI * 2, true); } else if (pattern === 'hexagon') { for (let k = 0; k < 6; k++) { + // Угол идет в минус -> CW направление const angle = (-k * 60 + 90) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); @@ -121,6 +134,7 @@ const createPerforatedShape = (length: number, height: number, config: AppConfig else if (pattern === 'triangle') { const rot = isOdd ? 180 : 0; for (let k = 0; k < 3; k++) { + // Угол идет в минус -> CW направление const angle = (-k * 120 + 90 + rot) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); @@ -134,6 +148,46 @@ const createPerforatedShape = (length: number, height: number, config: AppConfig return shape; }; +// Форма пола (без дырок) +const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { + const shape = new THREE.Shape(); + const x = -width / 2; + const y = -depth / 2; + const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); + + if (r <= 0.1) { + shape.moveTo(x, y); + shape.lineTo(x + width, y); + shape.lineTo(x + width, y + depth); + shape.lineTo(x, y + depth); + shape.lineTo(x, y); + } else { + shape.moveTo(x, y + r); + shape.lineTo(x, y + depth - r); + shape.quadraticCurveTo(x, y + depth, x + r, y + depth); + shape.lineTo(x + width - r, y + depth); + shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; +}; + +// Форма скругления (Cylinder sector) +const createFilletShape = (radius: number): THREE.Shape => { + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + shape.lineTo(radius, 0); + // Дуга + shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); + shape.lineTo(0, 0); + return shape; +}; + +// --- СБОРКА ВСЕЙ ГЕОМЕТРИИ --- + export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, splits: LayoutSplits | Partition[] = [], @@ -144,42 +198,47 @@ export const createBinGeometry = ( const safeConfig = config || { perforation: { enabled: false } } as AppConfig; // 1. ПОЛ - const floorGeo = new THREE.BoxGeometry(width, thickness, depth); - floorGeo.translate(0, thickness / 2, 0); + const floorShape = createFloorShape(width, depth, radius); + const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); + floorGeo.rotateX(-Math.PI / 2); // Кладем на землю geometries.push(floorGeo); const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // Хелпер: создает стену, экструдит, вращает и ставит на место - const addWall = (len: number, h: number, x: number, z: number, isVert: boolean) => { - // 1. 2D форма - const shape = createPerforatedShape(len, h, safeConfig); - // 2. Экструзия (Толщина) + // Функция для создания, экструзии и установки стены + const addWall = (length: number, h: number, x: number, z: number, isVertical: boolean) => { + // 1. Создаем 2D чертеж с дырками + const shape = createPerforatedShape(length, h, safeConfig); + + // 2. Выдавливаем (получаем толщину) const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - // 3. Центрирование геометрии (чтобы вращать вокруг центра) + // 3. Центрируем геометрию в локальных осях (чтобы вращать вокруг центра) geo.center(); + // Теперь координаты стены от -Len/2 до +Len/2 - // 4. Поворот и Позиционирование - if (isVert) { + // 4. Поворачиваем если нужно + if (isVertical) { geo.rotateY(Math.PI / 2); } - // Поднимаем на пол (thickness + h/2) + + // 5. Ставим на место + // Y: поднимаем на пол (thickness) + половина высоты (так как мы центрировали по Y) geo.translate(x, thickness + h/2, z); geometries.push(geo); }; // 2. ВНЕШНИЕ СТЕНЫ - // Front (вдоль X) + // Front (Вдоль X) addWall(innerW, wallH, 0, depth/2 - thickness/2, false); - // Back (вдоль X) + // Back (Вдоль X) addWall(innerW, wallH, 0, -depth/2 + thickness/2, false); - // Left (вдоль Z) + // Left (Вдоль Z, полная глубина) addWall(depth, wallH, -width/2 + thickness/2, 0, true); - // Right (вдоль Z) + // Right (Вдоль Z, полная глубина) addWall(depth, wallH, width/2 - thickness/2, 0, true); // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ @@ -190,6 +249,8 @@ export const createBinGeometry = ( partitions.forEach(p => { const pMin = p.min ?? 0; const pMax = p.max ?? 1; + + // Защита от мусорных данных if (Math.abs(pMax - pMin) < 0.001) return; let len = 0; @@ -197,55 +258,64 @@ export const createBinGeometry = ( let zPos = 0; let isVert = false; - if (p.axis === 'x') { // Vert (Z-axis) + // Рассчитываем позицию центра перегородки + if (p.axis === 'x') { // Vert (Вдоль Z) isVert = true; len = (pMax - pMin) * innerD; + // X позиция: от левого края innerW xPos = (-innerW/2) + (p.offset * innerW); + // Z центр: середина отрезка const midZ = (pMin + pMax) / 2; zPos = (-innerD/2) + (midZ * innerD); - } else { // Horiz (X-axis) + } else { // Horiz (Вдоль X) isVert = false; len = (pMax - pMin) * innerW; + // X центр: середина отрезка const midX = (pMin + pMax) / 2; xPos = (-innerW/2) + (midX * innerW); + // Z позиция: от заднего края innerD zPos = (-innerD/2) + (p.offset * innerD); } + // Создаем стенку addWall(len, p.height, xPos, zPos, isVert); // --- СКРУГЛЕНИЯ (СТОЛБИКИ) --- + // Добавляем цилиндры в торцы перегородок, если включено if (p.rounded && radius > 0) { const r = Math.min(radius, 5); - const cylGeo = new THREE.CylinderGeometry(r, r, p.height, 12); - // Поднимаем пивот в центр (так как addWall центрирует) - // Или просто позиционируем как есть + const cylGeo = new THREE.CylinderGeometry(r, r, p.height, 16); + // Центрируем по высоте, чтобы translate работал так же как для стен + // (по умолчанию cylinder pivot в центре, так что всё ок) - const addFillet = (fx: number, fz: number) => { + const addCyl = (cx: number, cz: number) => { const c = cylGeo.clone(); - c.translate(fx, thickness + p.height/2, fz); + c.translate(cx, thickness + p.height/2, cz); geometries.push(c); }; if (isVert) { const zStart = zPos - len/2; const zEnd = zPos + len/2; - addFillet(xPos, zStart); - addFillet(xPos, zEnd); + addCyl(xPos, zStart); + addCyl(xPos, zEnd); } else { const xStart = xPos - len/2; const xEnd = xPos + len/2; - addFillet(xStart, zPos); - addFillet(xEnd, zPos); + addCyl(xStart, zPos); + addCyl(xEnd, zPos); } } }); // 4. СЛИЯНИЕ const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); + if (merged) merged.computeVertexNormals(); // Исправляет тени и "прозрачность" return merged || new THREE.BoxGeometry(1, 1, 1); }; +// --- ЭКСПОРТ --- + export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); From 58cfc0f8e4edccc5d75a91c7fb4eb28f72780481 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: Mon, 12 Jan 2026 03:09:51 +0300 Subject: [PATCH 18/21] 9 --- src/services/geometryGenerator.ts | 353 ++++++++++++++++-------------- 1 file changed, 190 insertions(+), 163 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 314a610..d1c702b 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -4,26 +4,21 @@ import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// Очистка координат (убирает фантомные ячейки и дрожание) -const cleanPoints = (points: number[]) => { +// Очистка только для визуализации (цветные кубики), чтобы не рябило в глазах +const cleanPointsForVisuals = (points: number[]) => { const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); return [...new Set(rounded)]; }; -// Сбор всех перегородок в плоский список -const getAllPartitions = (splits: LayoutSplits): Partition[] => { - if (!splits || !splits.partitions) return []; - return Object.values(splits.partitions).flat(); -}; - -// --- ВИЗУАЛИЗАЦИЯ (Цветные кубики для шага 3) --- +// --- 1. ВИЗУАЛИЗАЦИЯ (ЦВЕТНЫЕ БЛОКИ) --- export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; - const uniqueX = cleanPoints([0, ...safeX, 1]); - const uniqueY = cleanPoints([0, ...safeY, 1]); + // Для визуализации используем очищенную сетку, чтобы было красиво + const uniqueX = cleanPointsForVisuals([0, ...safeX, 1]); + const uniqueY = cleanPointsForVisuals([0, ...safeY, 1]); let partCounter = 1; @@ -37,21 +32,19 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const rawW = (x2 - x1) * config.drawer.width; const rawD = (y2 - y1) * config.drawer.depth; - // Фильтр слишком мелких ячеек if (rawW < 2 || rawD < 2) continue; const rawX = x1 * config.drawer.width; const rawY = y1 * config.drawer.depth; - // Зазор для визуализации (чтобы блоки не слипались) - const gap = config.wallThickness / 2 + 0.15; + const gap = config.wallThickness / 2 + 0.1; parts.push({ id: `part-${partCounter}`, name: `Ячейка ${partCounter}`, width: Math.max(1, rawW - gap * 2), depth: Math.max(1, rawD - gap * 2), - height: config.drawer.height - config.wallThickness, // Высота без пола + height: config.drawer.height - config.wallThickness, x: rawX + gap, y: rawY + gap, color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, @@ -63,39 +56,37 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (NATIVE THREE.JS) --- +// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (СТРОГО ПО ДАННЫМ) --- -// Функция создания 2D формы с дырками -const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { +// Функция создания 2D профиля стены с отверстиями +const createWallProfile = (width: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); - // 1. Внешний контур: ПРОТИВ ЧАСОВОЙ (CCW) - // (0,0) -> (L,0) -> (L,H) -> (0,H) -> (0,0) + // 1. Внешний контур (CCW - Против часовой) shape.moveTo(0, 0); - shape.lineTo(length, 0); - shape.lineTo(length, height); + shape.lineTo(width, 0); + shape.lineTo(width, height); shape.lineTo(0, height); shape.lineTo(0, 0); - // Если перфорация выключена или стенка слишком маленькая для дырок - if (!config.perforation?.enabled || length < 15 || height < 15) return shape; + // Проверка на включение перфорации + if (!config.perforation?.enabled || width < 10 || height < 10) return shape; const { pattern, diameter, spacing } = config.perforation; const step = diameter + Math.max(2, spacing); - const margin = 4; // Отступ от краев стенки + const margin = 3; // Отступ от краев - // Эффективная зона для отверстий - const effW = length - margin * 2; + const effW = width - margin * 2; const effH = height - margin * 2; if (effW <= diameter || effH <= diameter) return shape; - // Расчет сетки + // Сетка отверстий const rowH = pattern === 'circle' ? step : step * 0.866; const cols = Math.floor(effW / step); const rows = Math.floor(effH / rowH); - // Центрирование сетки + // Центрирование const startX = margin + (effW - (cols - 1) * step) / 2; const startY = margin + (effH - (rows - 1) * rowH) / 2; @@ -108,22 +99,19 @@ const createPerforatedShape = (length: number, height: number, config: AppConfig // Смещение для сот/треугольников if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; - // Проверка, что отверстие не вылезает за пределы - if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || + // Проверка границ (чтобы дырка не вылезла за край) + if (cx - diameter/2 < margin || cx + diameter/2 > width - margin || cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; const hole = new THREE.Path(); const r = diameter / 2; - // 2. ВНУТРЕННИЕ ОТВЕРСТИЯ: ПО ЧАСОВОЙ (CW) - // Параметр aClockwise = true в absarc. Это критично! - + // 2. Дырки (CW - По часовой стрелке). ВАЖНО! if (pattern === 'circle') { hole.absarc(cx, cy, r, 0, Math.PI * 2, true); } else if (pattern === 'hexagon') { for (let k = 0; k < 6; k++) { - // Угол идет в минус -> CW направление const angle = (-k * 60 + 90) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); @@ -134,7 +122,6 @@ const createPerforatedShape = (length: number, height: number, config: AppConfig else if (pattern === 'triangle') { const rot = isOdd ? 180 : 0; for (let k = 0; k < 3; k++) { - // Угол идет в минус -> CW направление const angle = (-k * 120 + 90 + rot) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); @@ -148,45 +135,23 @@ const createPerforatedShape = (length: number, height: number, config: AppConfig return shape; }; -// Форма пола (без дырок) -const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - const x = -width / 2; - const y = -depth / 2; - const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); - - if (r <= 0.1) { - shape.moveTo(x, y); - shape.lineTo(x + width, y); - shape.lineTo(x + width, y + depth); - shape.lineTo(x, y + depth); - shape.lineTo(x, y); - } else { - shape.moveTo(x, y + r); - shape.lineTo(x, y + depth - r); - shape.quadraticCurveTo(x, y + depth, x + r, y + depth); - shape.lineTo(x + width - r, y + depth); - shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; +// Обычный прямоугольник для пола +const createRectShape = (w: number, d: number): THREE.Shape => { + const s = new THREE.Shape(); + s.moveTo(0,0); s.lineTo(w,0); s.lineTo(w,d); s.lineTo(0,d); s.lineTo(0,0); + return s; }; -// Форма скругления (Cylinder sector) -const createFilletShape = (radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(radius, 0); - // Дуга - shape.absarc(radius, radius, radius, -Math.PI / 2, -Math.PI, true); - shape.lineTo(0, 0); - return shape; +// Цилиндр для скругления +const createFilletGeo = (radius: number, height: number) => { + const r = Math.min(radius, 5); + const geo = new THREE.CylinderGeometry(r, r, height, 16); + // Центрируем по Y, чтобы ставить от пола + geo.translate(0, height/2, 0); + return geo; }; -// --- СБОРКА ВСЕЙ ГЕОМЕТРИИ --- +// --- СБОРЩИК ГЕОМЕТРИИ --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, @@ -198,124 +163,186 @@ export const createBinGeometry = ( const safeConfig = config || { perforation: { enabled: false } } as AppConfig; // 1. ПОЛ - const floorShape = createFloorShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // Кладем на землю + // Используем простую геометрию для пола + const floorGeo = new THREE.BoxGeometry(width, thickness, depth); + floorGeo.translate(width/2, thickness/2, depth/2); // Сдвигаем в 0..W, 0..D систему + // Но стоп, у нас система координат: центр ящика в 0,0,0? Или угол в 0,0,0? + // В calculateParts мы используем абсолютные значения (0..width). + // Давайте строить всё от угла (0,0,0) - так проще считать координаты. + + // Сбрасываем позицию пола: центр (W/2, T/2, D/2) + floorGeo.center(); + floorGeo.translate(width/2, thickness/2, depth/2); // Угол (0,0,0) - это левый задний угол пола geometries.push(floorGeo); const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // Функция для создания, экструзии и установки стены - const addWall = (length: number, h: number, x: number, z: number, isVertical: boolean) => { - // 1. Создаем 2D чертеж с дырками - const shape = createPerforatedShape(length, h, safeConfig); - - // 2. Выдавливаем (получаем толщину) + // Хелпер для установки стен + const addWall = (len: number, h: number, x: number, z: number, isVert: boolean) => { + // 2D профиль с дырками + const shape = createWallProfile(len, h, safeConfig); const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - // 3. Центрируем геометрию в локальных осях (чтобы вращать вокруг центра) - geo.center(); - // Теперь координаты стены от -Len/2 до +Len/2 - - // 4. Поворачиваем если нужно - if (isVertical) { - geo.rotateY(Math.PI / 2); + // По умолчанию: Shape в XY (0..L, 0..H), Extrude в Z (0..T) + + if (isVert) { + // Вертикальная (идет вдоль Z) + geo.rotateY(Math.PI / 2); // Теперь идет вдоль Z (0..L), толщина вдоль X (0..T) + // Позиция: X, Z. + // Начало: (0,0,0) -> повернулось. + // Нам нужно поставить начало стены в (x, thickness, z). + geo.translate(x, thickness, z); + } else { + // Горизонтальная (идет вдоль X) + // X = длина, Y = высота, Z = толщина. + // Нам нужно Z центрировать? Нет, обычно стенки имеют толщину. + // Ставим как есть. + geo.translate(x, thickness, z); } - - // 5. Ставим на место - // Y: поднимаем на пол (thickness) + половина высоты (так как мы центрировали по Y) - geo.translate(x, thickness + h/2, z); - geometries.push(geo); }; - // 2. ВНЕШНИЕ СТЕНЫ - // Front (Вдоль X) - addWall(innerW, wallH, 0, depth/2 - thickness/2, false); - // Back (Вдоль X) - addWall(innerW, wallH, 0, -depth/2 + thickness/2, false); - // Left (Вдоль Z, полная глубина) - addWall(depth, wallH, -width/2 + thickness/2, 0, true); - // Right (Вдоль Z, полная глубина) - addWall(depth, wallH, width/2 - thickness/2, 0, true); - - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - let partitions: Partition[] = []; - if (Array.isArray(splits)) partitions = splits; - else if (splits && splits.partitions) partitions = getAllPartitions(splits); - - partitions.forEach(p => { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; + // 2. ВНЕШНИЕ СТЕНЫ (Коробка) + // Используем систему координат 0..Width, 0..Depth + + // Задняя (вдоль X) + // X=thickness (внутри левой стены), Z=0 + // Длина = innerW + addWall(innerW, wallH, thickness, 0, false); + + // Передняя (вдоль X) + // X=thickness, Z=depth-thickness + addWall(innerW, wallH, thickness, depth - thickness, false); + + // Левая (вдоль Z) + // X=thickness (сдвиг из-за поворота), Z=0. + // При повороте на 90: (0,0,0) -> (0,0,0). Длина ушла в -Z? Или +Z? + // RotateY(PI/2): X->Z, Z->-X. + // Shape (L, 0, 0) -> (0, 0, -L). Стенка ушла в минус по Z. + // Нам нужно чтобы шла в плюс. RotateY(-PI/2). + + // Исправим хелпер для поворота: + // Если RotateY(-PI/2): X->-Z. + // Давайте проще: создадим и сдвинем. + + // LEFT (Полная глубина) + const leftGeo = new THREE.ExtrudeGeometry(createWallProfile(depth, wallH, safeConfig), { depth: thickness, bevelEnabled: false }); + leftGeo.rotateY(Math.PI / 2); // Вдоль Z + // После +90: начало (0,0,0). Длина вдоль -Z. Толщина вдоль -X. + // Нам нужно начало в (0,0,0). Стенка должна идти в +Z. + // rotateY(-PI/2) -> Длина в +Z. Толщина в +X. + leftGeo.rotateY(-Math.PI); // Коррекция + // Теперь она смотрит куда надо. + leftGeo.translate(thickness, thickness, 0); + // Стоп, это сложно угадать. + + // ДАВАЙТЕ ПРОЩЕ: Центрируем каждую стену и ставим по центру. + // Это 100% рабочий метод. + + const placeCenteredWall = (len: number, h: number, centerX: number, centerZ: number, isVert: boolean) => { + const shape = createWallProfile(len, h, safeConfig); + const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); + geo.center(); // Центр в (0,0,0) - // Защита от мусорных данных - if (Math.abs(pMax - pMin) < 0.001) return; + if (isVert) geo.rotateY(Math.PI / 2); + + // Ставим: Y = thickness + h/2 + geo.translate(centerX, thickness + h/2, centerZ); + geometries.push(geo); + }; - let len = 0; - let xPos = 0; - let zPos = 0; - let isVert = false; + // Пересчет центров для внешних стен: + // Центр пола: W/2, D/2. + placeCenteredWall(innerW, wallH, width/2, thickness/2, false); // Back (Z=thick/2) + placeCenteredWall(innerW, wallH, width/2, depth - thickness/2, false); // Front + + placeCenteredWall(depth, wallH, thickness/2, depth/2, true); // Left + placeCenteredWall(depth, wallH, width - thickness/2, depth/2, true); // Right - // Рассчитываем позицию центра перегородки - if (p.axis === 'x') { // Vert (Вдоль Z) - isVert = true; - len = (pMax - pMin) * innerD; - // X позиция: от левого края innerW - xPos = (-innerW/2) + (p.offset * innerW); - // Z центр: середина отрезка - const midZ = (pMin + pMax) / 2; - zPos = (-innerD/2) + (midZ * innerD); - } else { // Horiz (Вдоль X) - isVert = false; - len = (pMax - pMin) * innerW; - // X центр: середина отрезка - const midX = (pMin + pMax) / 2; - xPos = (-innerW/2) + (midX * innerW); - // Z позиция: от заднего края innerD - zPos = (-innerD/2) + (p.offset * innerD); - } - // Создаем стенку - addWall(len, p.height, xPos, zPos, isVert); + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (Связь с данными редактора) + // Берем исходные данные о сетке для расчета точных позиций + const splitX = [0, ...(Array.isArray(splits?.x) ? splits.x : []), 1].sort((a,b)=>a-b); + const splitY = [0, ...(Array.isArray(splits?.y) ? splits.y : []), 1].sort((a,b)=>a-b); + const partitionsObj = splits.partitions || {}; - // --- СКРУГЛЕНИЯ (СТОЛБИКИ) --- - // Добавляем цилиндры в торцы перегородок, если включено - if (p.rounded && radius > 0) { - const r = Math.min(radius, 5); - const cylGeo = new THREE.CylinderGeometry(r, r, p.height, 16); - // Центрируем по высоте, чтобы translate работал так же как для стен - // (по умолчанию cylinder pivot в центре, так что всё ок) - - const addCyl = (cx: number, cz: number) => { - const c = cylGeo.clone(); - c.translate(cx, thickness + p.height/2, cz); - geometries.push(c); - }; + // Проходим по всем ячейкам + Object.keys(partitionsObj).forEach(key => { + const parts = partitionsObj[key]; + if (!parts || parts.length === 0) return; - if (isVert) { - const zStart = zPos - len/2; - const zEnd = zPos + len/2; - addCyl(xPos, zStart); - addCyl(xPos, zEnd); - } else { - const xStart = xPos - len/2; - const xEnd = xPos + len/2; - addCyl(xStart, zPos); - addCyl(xEnd, zPos); + const [iStr, jStr] = key.split('-'); + const i = parseInt(iStr); + const j = parseInt(jStr); + + // Получаем границы ячейки (0..1) + const x1 = splitX[i]; + const x2 = splitX[i+1]; + const y1 = splitY[j]; + const y2 = splitY[j+1]; + + if (x2 === undefined || y2 === undefined) return; + + // Конвертируем в миллиметры + const cellX = x1 * width; + const cellY = y1 * depth; + const cellW = (x2 - x1) * width; + const cellD = (y2 - y1) * depth; + + parts.forEach(p => { + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; + if (Math.abs(pMax - pMin) < 0.001) return; + + let len = 0, cx = 0, cz = 0, isVert = false; + + if (p.axis === 'x') { // Vert (Z) + isVert = true; + len = (pMax - pMin) * cellD; + // X центр: начало ячейки + смещение + cx = cellX + (p.offset * cellW); + // Z центр: начало ячейки + середина отрезка стены + const midRatio = (pMin + pMax) / 2; + cz = cellY + (midRatio * cellD); + } else { // Horiz (X) + isVert = false; + len = (pMax - pMin) * cellW; + const midRatio = (pMin + pMax) / 2; + cx = cellX + (midRatio * cellW); + cz = cellY + (p.offset * cellD); } - } + + placeCenteredWall(len, p.height, cx, cz, isVert); + + // Скругления + if (p.rounded && radius > 0) { + const r = Math.min(radius, 5); + const fGeo = createFilletGeo(r, p.height); + // Определяем концы + if (isVert) { + const zStart = cz - len/2; + const zEnd = cz + len/2; + // Добавляем цилиндры в концы (подняв на пол) + const c1 = fGeo.clone(); c1.translate(cx, thickness, zStart); geometries.push(c1); + const c2 = fGeo.clone(); c2.translate(cx, thickness, zEnd); geometries.push(c2); + } else { + const xStart = cx - len/2; + const xEnd = cx + len/2; + const c1 = fGeo.clone(); c1.translate(xStart, thickness, cz); geometries.push(c1); + const c2 = fGeo.clone(); c2.translate(xEnd, thickness, cz); geometries.push(c2); + } + } + }); }); // 4. СЛИЯНИЕ const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); // Исправляет тени и "прозрачность" + if (merged) merged.computeVertexNormals(); return merged || new THREE.BoxGeometry(1, 1, 1); }; -// --- ЭКСПОРТ --- - export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); From 12406128769d72788beb4c4d8a31de9166e4bd89 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: Mon, 12 Jan 2026 14:05:51 +0300 Subject: [PATCH 19/21] 10 --- src/services/geometryGenerator.ts | 322 ++++++++++++------------------ 1 file changed, 128 insertions(+), 194 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index d1c702b..48c768d 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -4,21 +4,25 @@ import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; // --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- -// Очистка только для визуализации (цветные кубики), чтобы не рябило в глазах -const cleanPointsForVisuals = (points: number[]) => { - const rounded = points.map(p => Math.round(p * 1000) / 1000).sort((a, b) => a - b); - return [...new Set(rounded)]; +// Просто сортируем координаты, без агрессивной чистки, чтобы не терять ячейки +const sortPoints = (points: number[]) => { + return [...new Set(points)].sort((a, b) => a - b); }; -// --- 1. ВИЗУАЛИЗАЦИЯ (ЦВЕТНЫЕ БЛОКИ) --- +// Сбор всех перегородок в один массив +const getAllPartitions = (splits: LayoutSplits): Partition[] => { + if (!splits || !splits.partitions) return []; + return Object.values(splits.partitions).flat(); +}; + +// --- ВИЗУАЛИЗАЦИЯ (Цветные блоки) --- export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { const parts: GeneratedPart[] = []; const safeX = Array.isArray(splits?.x) ? splits.x : []; const safeY = Array.isArray(splits?.y) ? splits.y : []; - // Для визуализации используем очищенную сетку, чтобы было красиво - const uniqueX = cleanPointsForVisuals([0, ...safeX, 1]); - const uniqueY = cleanPointsForVisuals([0, ...safeY, 1]); + const uniqueX = sortPoints([0, ...safeX, 1]); + const uniqueY = sortPoints([0, ...safeY, 1]); let partCounter = 1; @@ -32,12 +36,14 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat const rawW = (x2 - x1) * config.drawer.width; const rawD = (y2 - y1) * config.drawer.depth; - if (rawW < 2 || rawD < 2) continue; + // Фильтр фантомов: если ячейка меньше 1 мм, пропускаем + if (rawW < 1 || rawD < 1) continue; const rawX = x1 * config.drawer.width; const rawY = y1 * config.drawer.depth; - const gap = config.wallThickness / 2 + 0.1; + // Зазор для визуализации + const gap = config.wallThickness / 2 + 0.2; parts.push({ id: `part-${partCounter}`, @@ -56,37 +62,34 @@ export const calculateParts = (config: AppConfig, splits: LayoutSplits): Generat return parts; }; -// --- ГЕНЕРАЦИЯ ГЕОМЕТРИИ (СТРОГО ПО ДАННЫМ) --- +// --- ГЕОМЕТРИЯ (Extrude с дырками) --- -// Функция создания 2D профиля стены с отверстиями -const createWallProfile = (width: number, height: number, config: AppConfig): THREE.Shape => { +const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { const shape = new THREE.Shape(); // 1. Внешний контур (CCW - Против часовой) shape.moveTo(0, 0); - shape.lineTo(width, 0); - shape.lineTo(width, height); + shape.lineTo(length, 0); + shape.lineTo(length, height); shape.lineTo(0, height); shape.lineTo(0, 0); - // Проверка на включение перфорации - if (!config.perforation?.enabled || width < 10 || height < 10) return shape; + // Если перфорация выключена или стенка мала + if (!config.perforation?.enabled || length < 15 || height < 15) return shape; const { pattern, diameter, spacing } = config.perforation; const step = diameter + Math.max(2, spacing); - const margin = 3; // Отступ от краев + const margin = 4; // Отступ от краев - const effW = width - margin * 2; + const effW = length - margin * 2; const effH = height - margin * 2; if (effW <= diameter || effH <= diameter) return shape; - // Сетка отверстий const rowH = pattern === 'circle' ? step : step * 0.866; const cols = Math.floor(effW / step); const rows = Math.floor(effH / rowH); - // Центрирование const startX = margin + (effW - (cols - 1) * step) / 2; const startY = margin + (effH - (rows - 1) * rowH) / 2; @@ -96,22 +99,24 @@ const createWallProfile = (width: number, height: number, config: AppConfig): TH for (let i = 0; i < cols; i++) { let cx = startX + i * step; - // Смещение для сот/треугольников if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; - // Проверка границ (чтобы дырка не вылезла за край) - if (cx - diameter/2 < margin || cx + diameter/2 > width - margin || + // Проверка границ (центр + радиус) + if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; const hole = new THREE.Path(); const r = diameter / 2; - // 2. Дырки (CW - По часовой стрелке). ВАЖНО! + // 2. ОТВЕРСТИЯ (CW - По часовой стрелке) + // Это ключ к успеху! aClockwise = true + if (pattern === 'circle') { hole.absarc(cx, cy, r, 0, Math.PI * 2, true); } else if (pattern === 'hexagon') { for (let k = 0; k < 6; k++) { + // Угол (-k) дает направление по часовой const angle = (-k * 60 + 90) * Math.PI / 180; const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); @@ -135,23 +140,34 @@ const createWallProfile = (width: number, height: number, config: AppConfig): TH return shape; }; -// Обычный прямоугольник для пола -const createRectShape = (w: number, d: number): THREE.Shape => { - const s = new THREE.Shape(); - s.moveTo(0,0); s.lineTo(w,0); s.lineTo(w,d); s.lineTo(0,d); s.lineTo(0,0); - return s; +// Пол (сплошной) +const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { + const shape = new THREE.Shape(); + const x = -width / 2; + const y = -depth / 2; + const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); + + if (r <= 0.1) { + shape.moveTo(x, y); + shape.lineTo(x + width, y); + shape.lineTo(x + width, y + depth); + shape.lineTo(x, y + depth); + shape.lineTo(x, y); + } else { + shape.moveTo(x, y + r); + shape.lineTo(x, y + depth - r); + shape.quadraticCurveTo(x, y + depth, x + r, y + depth); + shape.lineTo(x + width - r, y + depth); + shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; }; -// Цилиндр для скругления -const createFilletGeo = (radius: number, height: number) => { - const r = Math.min(radius, 5); - const geo = new THREE.CylinderGeometry(r, r, height, 16); - // Центрируем по Y, чтобы ставить от пола - geo.translate(0, height/2, 0); - return geo; -}; - -// --- СБОРЩИК ГЕОМЕТРИИ --- +// --- СБОРКА МОДЕЛИ --- export const createBinGeometry = ( width: number, depth: number, height: number, thickness: number, radius: number = 0, @@ -163,181 +179,99 @@ export const createBinGeometry = ( const safeConfig = config || { perforation: { enabled: false } } as AppConfig; // 1. ПОЛ - // Используем простую геометрию для пола - const floorGeo = new THREE.BoxGeometry(width, thickness, depth); - floorGeo.translate(width/2, thickness/2, depth/2); // Сдвигаем в 0..W, 0..D систему - // Но стоп, у нас система координат: центр ящика в 0,0,0? Или угол в 0,0,0? - // В calculateParts мы используем абсолютные значения (0..width). - // Давайте строить всё от угла (0,0,0) - так проще считать координаты. - - // Сбрасываем позицию пола: центр (W/2, T/2, D/2) - floorGeo.center(); - floorGeo.translate(width/2, thickness/2, depth/2); // Угол (0,0,0) - это левый задний угол пола + const floorShape = createFloorShape(width, depth, radius); + const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); + floorGeo.rotateX(-Math.PI / 2); // Кладем на пол geometries.push(floorGeo); const wallH = height - thickness; const innerW = width - 2 * thickness; const innerD = depth - 2 * thickness; - // Хелпер для установки стен - const addWall = (len: number, h: number, x: number, z: number, isVert: boolean) => { - // 2D профиль с дырками - const shape = createWallProfile(len, h, safeConfig); + // Функция добавления стены + const addWall = (len: number, h: number, x: number, z: number, isVertical: boolean) => { + // Создаем 2D форму с дырками + const shape = createPerforatedShape(len, h, safeConfig); + // Выдавливаем const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - // По умолчанию: Shape в XY (0..L, 0..H), Extrude в Z (0..T) - - if (isVert) { - // Вертикальная (идет вдоль Z) - geo.rotateY(Math.PI / 2); // Теперь идет вдоль Z (0..L), толщина вдоль X (0..T) - // Позиция: X, Z. - // Начало: (0,0,0) -> повернулось. - // Нам нужно поставить начало стены в (x, thickness, z). - geo.translate(x, thickness, z); - } else { - // Горизонтальная (идет вдоль X) - // X = длина, Y = высота, Z = толщина. - // Нам нужно Z центрировать? Нет, обычно стенки имеют толщину. - // Ставим как есть. - geo.translate(x, thickness, z); + // Центрируем геометрию (важно для вращения!) + geo.center(); + + // Поворачиваем + if (isVertical) { + geo.rotateY(Math.PI / 2); } + + // Ставим на место. Y = толщина пола + половина высоты стены + geo.translate(x, thickness + h/2, z); + geometries.push(geo); }; - // 2. ВНЕШНИЕ СТЕНЫ (Коробка) - // Используем систему координат 0..Width, 0..Depth - - // Задняя (вдоль X) - // X=thickness (внутри левой стены), Z=0 - // Длина = innerW - addWall(innerW, wallH, thickness, 0, false); - - // Передняя (вдоль X) - // X=thickness, Z=depth-thickness - addWall(innerW, wallH, thickness, depth - thickness, false); - - // Левая (вдоль Z) - // X=thickness (сдвиг из-за поворота), Z=0. - // При повороте на 90: (0,0,0) -> (0,0,0). Длина ушла в -Z? Или +Z? - // RotateY(PI/2): X->Z, Z->-X. - // Shape (L, 0, 0) -> (0, 0, -L). Стенка ушла в минус по Z. - // Нам нужно чтобы шла в плюс. RotateY(-PI/2). - - // Исправим хелпер для поворота: - // Если RotateY(-PI/2): X->-Z. - // Давайте проще: создадим и сдвинем. - - // LEFT (Полная глубина) - const leftGeo = new THREE.ExtrudeGeometry(createWallProfile(depth, wallH, safeConfig), { depth: thickness, bevelEnabled: false }); - leftGeo.rotateY(Math.PI / 2); // Вдоль Z - // После +90: начало (0,0,0). Длина вдоль -Z. Толщина вдоль -X. - // Нам нужно начало в (0,0,0). Стенка должна идти в +Z. - // rotateY(-PI/2) -> Длина в +Z. Толщина в +X. - leftGeo.rotateY(-Math.PI); // Коррекция - // Теперь она смотрит куда надо. - leftGeo.translate(thickness, thickness, 0); - // Стоп, это сложно угадать. - - // ДАВАЙТЕ ПРОЩЕ: Центрируем каждую стену и ставим по центру. - // Это 100% рабочий метод. - - const placeCenteredWall = (len: number, h: number, centerX: number, centerZ: number, isVert: boolean) => { - const shape = createWallProfile(len, h, safeConfig); - const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); - geo.center(); // Центр в (0,0,0) + // 2. ВНЕШНИЕ СТЕНЫ + // Front (вдоль X) + addWall(innerW, wallH, 0, depth/2 - thickness/2, false); + // Back (вдоль X) + addWall(innerW, wallH, 0, -depth/2 + thickness/2, false); + // Left (вдоль Z) + addWall(depth, wallH, -width/2 + thickness/2, 0, true); + // Right (вдоль Z) + addWall(depth, wallH, width/2 - thickness/2, 0, true); + + // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ + let partitions: Partition[] = []; + if (Array.isArray(splits)) { + partitions = splits; + } else if (splits && splits.partitions) { + partitions = getAllPartitions(splits); + } + + partitions.forEach(p => { + const pMin = p.min ?? 0; + const pMax = p.max ?? 1; - if (isVert) geo.rotateY(Math.PI / 2); - - // Ставим: Y = thickness + h/2 - geo.translate(centerX, thickness + h/2, centerZ); - geometries.push(geo); - }; + if (Math.abs(pMax - pMin) < 0.001) return; - // Пересчет центров для внешних стен: - // Центр пола: W/2, D/2. - placeCenteredWall(innerW, wallH, width/2, thickness/2, false); // Back (Z=thick/2) - placeCenteredWall(innerW, wallH, width/2, depth - thickness/2, false); // Front - - placeCenteredWall(depth, wallH, thickness/2, depth/2, true); // Left - placeCenteredWall(depth, wallH, width - thickness/2, depth/2, true); // Right + let len = 0, xPos = 0, zPos = 0, isVert = false; + if (p.axis === 'x') { // Vert (Z) + isVert = true; + len = (pMax - pMin) * innerD; + xPos = (-innerW/2) + (p.offset * innerW); + zPos = (-innerD/2) + ((pMin + pMax) / 2 * innerD); + } else { // Horiz (X) + isVert = false; + len = (pMax - pMin) * innerW; + xPos = (-innerW/2) + ((pMin + pMax) / 2 * innerW); + zPos = (-innerD/2) + (p.offset * innerD); + } - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ (Связь с данными редактора) - // Берем исходные данные о сетке для расчета точных позиций - const splitX = [0, ...(Array.isArray(splits?.x) ? splits.x : []), 1].sort((a,b)=>a-b); - const splitY = [0, ...(Array.isArray(splits?.y) ? splits.y : []), 1].sort((a,b)=>a-b); - const partitionsObj = splits.partitions || {}; + addWall(len, p.height, xPos, zPos, isVert); - // Проходим по всем ячейкам - Object.keys(partitionsObj).forEach(key => { - const parts = partitionsObj[key]; - if (!parts || parts.length === 0) return; + // 4. СКРУГЛЕНИЯ (Простые цилиндры в стыках) + if (p.rounded && radius > 0) { + const r = Math.min(radius, 5); + const cyl = new THREE.CylinderGeometry(r, r, p.height, 12); + + const addCyl = (cx: number, cz: number) => { + const c = cyl.clone(); + // Центрируем по высоте так же, как стены + c.translate(cx, thickness + p.height/2, cz); + geometries.push(c); + }; - const [iStr, jStr] = key.split('-'); - const i = parseInt(iStr); - const j = parseInt(jStr); - - // Получаем границы ячейки (0..1) - const x1 = splitX[i]; - const x2 = splitX[i+1]; - const y1 = splitY[j]; - const y2 = splitY[j+1]; - - if (x2 === undefined || y2 === undefined) return; - - // Конвертируем в миллиметры - const cellX = x1 * width; - const cellY = y1 * depth; - const cellW = (x2 - x1) * width; - const cellD = (y2 - y1) * depth; - - parts.forEach(p => { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; - if (Math.abs(pMax - pMin) < 0.001) return; - - let len = 0, cx = 0, cz = 0, isVert = false; - - if (p.axis === 'x') { // Vert (Z) - isVert = true; - len = (pMax - pMin) * cellD; - // X центр: начало ячейки + смещение - cx = cellX + (p.offset * cellW); - // Z центр: начало ячейки + середина отрезка стены - const midRatio = (pMin + pMax) / 2; - cz = cellY + (midRatio * cellD); - } else { // Horiz (X) - isVert = false; - len = (pMax - pMin) * cellW; - const midRatio = (pMin + pMax) / 2; - cx = cellX + (midRatio * cellW); - cz = cellY + (p.offset * cellD); + if (isVert) { + addCyl(xPos, zPos - len/2); // Начало + addCyl(xPos, zPos + len/2); // Конец + } else { + addCyl(xPos - len/2, zPos); // Начало + addCyl(xPos + len/2, zPos); // Конец } - - placeCenteredWall(len, p.height, cx, cz, isVert); - - // Скругления - if (p.rounded && radius > 0) { - const r = Math.min(radius, 5); - const fGeo = createFilletGeo(r, p.height); - // Определяем концы - if (isVert) { - const zStart = cz - len/2; - const zEnd = cz + len/2; - // Добавляем цилиндры в концы (подняв на пол) - const c1 = fGeo.clone(); c1.translate(cx, thickness, zStart); geometries.push(c1); - const c2 = fGeo.clone(); c2.translate(cx, thickness, zEnd); geometries.push(c2); - } else { - const xStart = cx - len/2; - const xEnd = cx + len/2; - const c1 = fGeo.clone(); c1.translate(xStart, thickness, cz); geometries.push(c1); - const c2 = fGeo.clone(); c2.translate(xEnd, thickness, cz); geometries.push(c2); - } - } - }); + } }); - // 4. СЛИЯНИЕ + // 5. СЛИЯНИЕ const merged = mergeBufferGeometries(geometries); if (merged) merged.computeVertexNormals(); return merged || new THREE.BoxGeometry(1, 1, 1); From f588da18201c50e133a3491fe18ee084e7fd227e 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: Mon, 12 Jan 2026 14:59:08 +0300 Subject: [PATCH 20/21] 11 --- src/services/geometryGenerator.ts | 412 ++++++++++++++---------------- 1 file changed, 186 insertions(+), 226 deletions(-) diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index 48c768d..a16108a 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -1,286 +1,246 @@ import * as THREE from 'three'; import { STLExporter, mergeBufferGeometries } from 'three-stdlib'; -import { AppConfig, LayoutSplits, GeneratedPart, Partition } from '../types'; +import { AppConfig, LayoutSplits, GeneratedPart, PerforationConfig } from '../types'; -// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- - -// Просто сортируем координаты, без агрессивной чистки, чтобы не терять ячейки -const sortPoints = (points: number[]) => { - return [...new Set(points)].sort((a, b) => a - b); -}; - -// Сбор всех перегородок в один массив -const getAllPartitions = (splits: LayoutSplits): Partition[] => { - if (!splits || !splits.partitions) return []; - return Object.values(splits.partitions).flat(); -}; - -// --- ВИЗУАЛИЗАЦИЯ (Цветные блоки) --- -export const calculateParts = (config: AppConfig, splits: LayoutSplits): GeneratedPart[] => { +/** + * 1. Расчет списка ящиков на основе сетки + * Это создает массив отдельных коробочек, которые визуально образуют органайзер + */ +export const calculateParts = ( + config: AppConfig, + splits: LayoutSplits +): GeneratedPart[] => { const parts: GeneratedPart[] = []; - const safeX = Array.isArray(splits?.x) ? splits.x : []; - const safeY = Array.isArray(splits?.y) ? splits.y : []; - const uniqueX = sortPoints([0, ...safeX, 1]); - const uniqueY = sortPoints([0, ...safeY, 1]); + // Сортируем линии реза и добавляем границы (0 и 1) + 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; - for (let i = 0; i < uniqueX.length - 1; i++) { - for (let j = 0; j < uniqueY.length - 1; j++) { - const x1 = uniqueX[i]; - const x2 = uniqueX[i+1]; - const y1 = uniqueY[j]; - const y2 = uniqueY[j+1]; - - const rawW = (x2 - x1) * config.drawer.width; - const rawD = (y2 - y1) * config.drawer.depth; + for (let i = 0; i < xPoints.length - 1; i++) { + for (let j = 0; j < yPoints.length - 1; j++) { - // Фильтр фантомов: если ячейка меньше 1 мм, пропускаем - if (rawW < 1 || rawD < 1) continue; + // Размеры текущей ячейки сетки + const segmentX = xPoints[i] * config.drawer.width; + const segmentY = yPoints[j] * config.drawer.depth; + const segmentW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; + const segmentD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; - const rawX = x1 * config.drawer.width; - const rawY = y1 * config.drawer.depth; - - // Зазор для визуализации - const gap = config.wallThickness / 2 + 0.2; + // Применяем толерантность (зазор между ящиками) + // Уменьшаем размер ящика, сдвигаем его к центру + const realWidth = segmentW - config.printerTolerance; + const realDepth = segmentD - config.printerTolerance; + const realX = segmentX + (config.printerTolerance / 2); + const realY = segmentY + (config.printerTolerance / 2); + + // Защита от слишком мелких (фантомных) ячеек + if (realWidth < 2 || realDepth < 2) { + continue; + } parts.push({ - id: `part-${partCounter}`, - name: `Ячейка ${partCounter}`, - width: Math.max(1, rawW - gap * 2), - depth: Math.max(1, rawD - gap * 2), - height: config.drawer.height - config.wallThickness, - x: rawX + gap, - y: rawY + gap, - color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)`, - internalPartitions: [] + id: `part-${partCounter}-${Date.now()}`, // Уникальный ID + name: `Ячейка ${i+1}-${j+1}`, + width: realWidth, + depth: realDepth, + height: config.drawer.height, + x: realX, + y: realY, + color: `hsl(${(partCounter * 137.5) % 360}, 70%, 50%)` }); partCounter++; } } + return parts; }; -// --- ГЕОМЕТРИЯ (Extrude с дырками) --- - -const createPerforatedShape = (length: number, height: number, config: AppConfig): THREE.Shape => { +/** + * 2. Создание 2D профиля стены с отверстиями + * ВАЖНО: Контур стены -> CCW (Против часовой) + * ВАЖНО: Отверстия -> CW (По часовой) + */ +const createPerforatedWallShape = ( + width: number, + height: number, + perf: PerforationConfig +): THREE.Shape => { const shape = new THREE.Shape(); - // 1. Внешний контур (CCW - Против часовой) + // Внешний прямоугольник (Против часовой стрелки) shape.moveTo(0, 0); - shape.lineTo(length, 0); - shape.lineTo(length, height); + shape.lineTo(width, 0); + shape.lineTo(width, height); shape.lineTo(0, height); shape.lineTo(0, 0); - // Если перфорация выключена или стенка мала - if (!config.perforation?.enabled || length < 15 || height < 15) return shape; + if (!perf.enabled) return shape; - const { pattern, diameter, spacing } = config.perforation; - const step = diameter + Math.max(2, spacing); - const margin = 4; // Отступ от краев + const { size, spacing, shape: type, border } = perf; + + // Эффективная зона перфорации + const startX = border; + const endX = width - border; + const startY = border; + const endY = height - border; - const effW = length - margin * 2; - const effH = height - margin * 2; + if (startX >= endX || startY >= endY) return shape; - if (effW <= diameter || effH <= diameter) return shape; + // Функция добавления одной дырки + const addHole = (cx: number, cy: number) => { + // Проверка границ (центр отверстия не должен выходить за рамки) + if (cx - size/2 < startX || cx + size/2 > endX || cy - size/2 < startY || cy + size/2 > endY) return; - const rowH = pattern === 'circle' ? step : step * 0.866; - const cols = Math.floor(effW / step); - const rows = Math.floor(effH / rowH); + const holePath = new THREE.Path(); + const r = size / 2; - const startX = margin + (effW - (cols - 1) * step) / 2; - const startY = margin + (effH - (rows - 1) * rowH) / 2; + if (type === 'circle') { + // aClockwise = true (По часовой стрелке) + holePath.absarc(cx, cy, r, 0, Math.PI * 2, true); + } else if (type === 'hexagon') { + // Шестиугольник (По часовой стрелке) + // angle идет в минус: 90, 30, -30... + for (let k = 0; k < 6; k++) { + const angle = (-k * 60 + 90) * (Math.PI / 180); + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (k === 0) holePath.moveTo(px, py); + else holePath.lineTo(px, py); + } + holePath.closePath(); + } else if (type === 'triangle') { + // Треугольник (По часовой стрелке) + const angles = [90, -30, 210]; // 90 -> -30 (CW) + angles.forEach((deg, idx) => { + const rad = deg * (Math.PI / 180); + const px = cx + r * Math.cos(rad); + const py = cy + r * Math.sin(rad); + if (idx === 0) holePath.moveTo(px, py); + else holePath.lineTo(px, py); + }); + holePath.closePath(); + } - for (let j = 0; j < rows; j++) { - const isOdd = j % 2 !== 0; - const cy = startY + j * rowH; + shape.holes.push(holePath); + }; - for (let i = 0; i < cols; i++) { - let cx = startX + i * step; - if ((pattern === 'hexagon' || pattern === 'triangle') && isOdd) cx += step / 2; - - // Проверка границ (центр + радиус) - if (cx - diameter/2 < margin || cx + diameter/2 > length - margin || - cy - diameter/2 < margin || cy + diameter/2 > height - margin) continue; - - const hole = new THREE.Path(); - const r = diameter / 2; - - // 2. ОТВЕРСТИЯ (CW - По часовой стрелке) - // Это ключ к успеху! aClockwise = true + // Генерация сетки + if (type === 'hexagon') { + // Сотовая структура (смещенные ряды) + const hexWidth = size * 0.866; // sqrt(3)/2 + const colDist = hexWidth + spacing; + const rowDist = (size * 0.75) + spacing; + + let rowIndex = 0; + for (let y = startY + size/2; y < endY; y += rowDist) { + const isOddRow = rowIndex % 2 === 1; + const offset = isOddRow ? colDist / 2 : 0; - if (pattern === 'circle') { - hole.absarc(cx, cy, r, 0, Math.PI * 2, true); - } - else if (pattern === 'hexagon') { - for (let k = 0; k < 6; k++) { - // Угол (-k) дает направление по часовой - const angle = (-k * 60 + 90) * Math.PI / 180; - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); - } - hole.closePath(); - } - else if (pattern === 'triangle') { - const rot = isOdd ? 180 : 0; - for (let k = 0; k < 3; k++) { - const angle = (-k * 120 + 90 + rot) * Math.PI / 180; - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - if (k === 0) hole.moveTo(px, py); else hole.lineTo(px, py); - } - hole.closePath(); + for (let x = startX + size/2 + offset; x < endX; x += colDist) { + addHole(x, y); + } + rowIndex++; + } + } else { + // Обычная сетка (Круг, Треугольник) + const cellSize = size + spacing; + for (let x = startX + size/2; x < endX; x += cellSize) { + for (let y = startY + size/2; y < endY; y += cellSize) { + addHole(x, y); } - shape.holes.push(hole); } } + return shape; }; -// Пол (сплошной) -const createFloorShape = (width: number, depth: number, radius: number): THREE.Shape => { - const shape = new THREE.Shape(); - const x = -width / 2; - const y = -depth / 2; - const r = Math.min(radius, width / 2 - 0.1, depth / 2 - 0.1); - - if (r <= 0.1) { - shape.moveTo(x, y); - shape.lineTo(x + width, y); - shape.lineTo(x + width, y + depth); - shape.lineTo(x, y + depth); - shape.lineTo(x, y); - } else { - shape.moveTo(x, y + r); - shape.lineTo(x, y + depth - r); - shape.quadraticCurveTo(x, y + depth, x + r, y + depth); - shape.lineTo(x + width - r, y + depth); - shape.quadraticCurveTo(x + width, y + depth, x + width, y + depth - 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; -}; - -// --- СБОРКА МОДЕЛИ --- - +/** + * 3. Создание 3D геометрии для ОДНОГО ящика + */ export const createBinGeometry = ( - width: number, depth: number, height: number, thickness: number, radius: number = 0, - splits: LayoutSplits | Partition[] = [], - config?: AppConfig + width: number, + depth: number, + height: number, + thickness: number, + perforation?: PerforationConfig ): THREE.BufferGeometry => { - const geometries: THREE.BufferGeometry[] = []; - const safeConfig = config || { perforation: { enabled: false } } as AppConfig; - - // 1. ПОЛ - const floorShape = createFloorShape(width, depth, radius); - const floorGeo = new THREE.ExtrudeGeometry(floorShape, { depth: thickness, bevelEnabled: false }); - floorGeo.rotateX(-Math.PI / 2); // Кладем на пол + const perfConfig = perforation || { enabled: false, shape: 'circle', size: 0, spacing: 0, border: 0 }; + + // 1. Пол (Всегда сплошной) + const floorGeo = new THREE.BoxGeometry(width, thickness, depth).toNonIndexed(); + floorGeo.translate(0, thickness / 2, 0); geometries.push(floorGeo); - const wallH = height - thickness; - const innerW = width - 2 * thickness; - const innerD = depth - 2 * thickness; + const wallHeight = height - thickness; + + if (wallHeight > 0) { + const extrudeSettings = { + depth: thickness, + bevelEnabled: false, + }; - // Функция добавления стены - const addWall = (len: number, h: number, x: number, z: number, isVertical: boolean) => { - // Создаем 2D форму с дырками - const shape = createPerforatedShape(len, h, safeConfig); - // Выдавливаем - const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }); + // 2. Левая и Правая стенки (Полная глубина) + // Рисуем профиль (Ширина профиля = Глубине ящика) + const lrShape = createPerforatedWallShape(depth, wallHeight, perfConfig); + const lrGeo = new THREE.ExtrudeGeometry(lrShape, extrudeSettings).toNonIndexed(); - // Центрируем геометрию (важно для вращения!) - geo.center(); + // Центрируем геометрию для удобного вращения + lrGeo.center(); - // Поворачиваем - if (isVertical) { - geo.rotateY(Math.PI / 2); + // Левая стенка (Left) + // Поворачиваем: Профиль лежит вдоль X -> поворот на 90 -> вдоль Z + const leftWall = lrGeo.clone(); + leftWall.rotateY(Math.PI / 2); + // Позиция: X = -width/2 + thickness/2, Y = пол + пол_стены + leftWall.translate(-(width/2) + thickness/2, thickness + wallHeight/2, 0); + geometries.push(leftWall); + + // Правая стенка (Right) + const rightWall = lrGeo.clone(); + rightWall.rotateY(Math.PI / 2); + rightWall.translate((width/2) - thickness/2, thickness + wallHeight/2, 0); + geometries.push(rightWall); + + // 3. Передняя и Задняя стенки (Вставляются МЕЖДУ боковыми) + // Их ширина меньше на 2 толщины + const wallFBWidth = width - (2 * thickness); + + if (wallFBWidth > 0) { + const fbShape = createPerforatedWallShape(wallFBWidth, wallHeight, perfConfig); + const fbGeo = new THREE.ExtrudeGeometry(fbShape, extrudeSettings).toNonIndexed(); + + fbGeo.center(); + + // Передняя стенка (Front) + const frontWall = fbGeo.clone(); + frontWall.translate(0, thickness + wallHeight/2, (depth/2) - thickness/2); + geometries.push(frontWall); + + // Задняя стенка (Back) + const backWall = fbGeo.clone(); + backWall.translate(0, thickness + wallHeight/2, -(depth/2) + thickness/2); + geometries.push(backWall); } - - // Ставим на место. Y = толщина пола + половина высоты стены - geo.translate(x, thickness + h/2, z); - - geometries.push(geo); - }; - - // 2. ВНЕШНИЕ СТЕНЫ - // Front (вдоль X) - addWall(innerW, wallH, 0, depth/2 - thickness/2, false); - // Back (вдоль X) - addWall(innerW, wallH, 0, -depth/2 + thickness/2, false); - // Left (вдоль Z) - addWall(depth, wallH, -width/2 + thickness/2, 0, true); - // Right (вдоль Z) - addWall(depth, wallH, width/2 - thickness/2, 0, true); - - // 3. ВНУТРЕННИЕ ПЕРЕГОРОДКИ - let partitions: Partition[] = []; - if (Array.isArray(splits)) { - partitions = splits; - } else if (splits && splits.partitions) { - partitions = getAllPartitions(splits); } - partitions.forEach(p => { - const pMin = p.min ?? 0; - const pMax = p.max ?? 1; - - if (Math.abs(pMax - pMin) < 0.001) return; - - let len = 0, xPos = 0, zPos = 0, isVert = false; - - if (p.axis === 'x') { // Vert (Z) - isVert = true; - len = (pMax - pMin) * innerD; - xPos = (-innerW/2) + (p.offset * innerW); - zPos = (-innerD/2) + ((pMin + pMax) / 2 * innerD); - } else { // Horiz (X) - isVert = false; - len = (pMax - pMin) * innerW; - xPos = (-innerW/2) + ((pMin + pMax) / 2 * innerW); - zPos = (-innerD/2) + (p.offset * innerD); - } - - addWall(len, p.height, xPos, zPos, isVert); - - // 4. СКРУГЛЕНИЯ (Простые цилиндры в стыках) - if (p.rounded && radius > 0) { - const r = Math.min(radius, 5); - const cyl = new THREE.CylinderGeometry(r, r, p.height, 12); - - const addCyl = (cx: number, cz: number) => { - const c = cyl.clone(); - // Центрируем по высоте так же, как стены - c.translate(cx, thickness + p.height/2, cz); - geometries.push(c); - }; - - if (isVert) { - addCyl(xPos, zPos - len/2); // Начало - addCyl(xPos, zPos + len/2); // Конец - } else { - addCyl(xPos - len/2, zPos); // Начало - addCyl(xPos + len/2, zPos); // Конец - } - } - }); - - // 5. СЛИЯНИЕ + // Сливаем всё в один меш const merged = mergeBufferGeometries(geometries); if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1); + + return merged || new THREE.BoxGeometry(1, 1, 1).toNonIndexed(); }; +// --- ЭКСПОРТ (без изменений) --- + export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true }); - if (result instanceof DataView) return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); + + if (result instanceof DataView) { + return new Uint8Array(result.buffer, result.byteOffset, result.byteLength); + } return result as string; }; From 3d774880b648e9d851d24b91f029ac7d0b9709e9 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: Mon, 12 Jan 2026 15:07:57 +0300 Subject: [PATCH 21/21] 12 --- src/components/PreviewStep.tsx | 376 ++++++++++++------------------ src/services/geometryGenerator.ts | 106 ++++----- 2 files changed, 189 insertions(+), 293 deletions(-) diff --git a/src/components/PreviewStep.tsx b/src/components/PreviewStep.tsx index 8334b76..0a394bb 100644 --- a/src/components/PreviewStep.tsx +++ b/src/components/PreviewStep.tsx @@ -1,17 +1,18 @@ -import React, { Suspense, useEffect, useRef, useState, useMemo } from 'react'; +import React, { useMemo, Suspense, useEffect, useRef, useState } from 'react'; import { Canvas } from '@react-three/fiber'; import { OrbitControls, Center, Environment } from '@react-three/drei'; import * as THREE from 'three'; import JSZip from 'jszip'; -import { AppConfig, GeneratedPart, LayoutSplits } from '../types'; -import { createBinGeometry, generateSTL, exportSTL } from '../services/geometryGenerator'; -import { Download, Package, Info, Loader2, Share2, Check, Ruler } from 'lucide-react'; -import { generateShareUrl } from '../utils/share'; +import { AppConfig, GeneratedPart } from '../types'; +import { createBinGeometry, exportSTL, generateSTL } from '../services/geometryGenerator'; +import { Download, Package, Info, Loader2 } from 'lucide-react'; + +// --- 3D Helper Components --- -// --- DrawerFrame (Каркас ящика) --- const DrawerFrame = ({ config }: { config: AppConfig }) => { const { width, depth, height } = config.drawer; const offset = 0.5; + return ( @@ -22,49 +23,43 @@ const DrawerFrame = ({ config }: { config: AppConfig }) => { ) } -// --- BinMesh (Ячейка) --- +// --- Bin Component --- + interface BinMeshProps { part: GeneratedPart; - thickness: number; - cornerRadius: number; + config: AppConfig; isSelected: boolean; onClick: () => void; } -const BinMesh: React.FC = ({ part, thickness, cornerRadius, isSelected, onClick }) => { - // 1. Создаем геометрию, учитывая ВНУТРЕННИЕ ПЕРЕГОРОДКИ +const BinMesh: React.FC = ({ part, config, isSelected, onClick }) => { + // Мемоизация геометрии для производительности const geometry = useMemo(() => { return createBinGeometry( part.width, part.depth, part.height, - thickness, - cornerRadius, - part.internalPartitions // <--- ВАЖНО: передаем перегородки в генератор + config.wallThickness, + config.perforation // Передаем конфиг перфорации! ); - }, [part, thickness, cornerRadius]); - - // 2. Создаем контур выделения (EdgesGeometry) - // Threshold 20 градусов скрывает линии на плавных скруглениях - const edgesGeometry = useMemo(() => { - return new THREE.EdgesGeometry(geometry, 20); - }, [geometry]); + }, [part, config.wallThickness, config.perforation]); return ( - {/* Сама модель */} - { e.stopPropagation(); onClick(); }}> + { e.stopPropagation(); onClick(); }} + > - {/* Белая подсветка при выборе */} {isSelected && ( - + + )} @@ -72,260 +67,181 @@ const BinMesh: React.FC = ({ part, thickness, cornerRadius, isSele ); }; -// --- PreviewStep (Основной компонент) --- interface Props { parts: GeneratedPart[]; config: AppConfig; - splits: LayoutSplits; } -export const PreviewStep: React.FC = ({ parts, config, splits }) => { +export const PreviewStep: React.FC = ({ parts, config }) => { const [selectedId, setSelectedId] = useState(null); const [isZipping, setIsZipping] = useState(false); - const [shareUrlCopied, setShareUrlCopied] = useState(false); const itemRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); - // Скролл к выбранной детали в списке useEffect(() => { if (selectedId && itemRefs.current[selectedId]) { - itemRefs.current[selectedId]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + itemRefs.current[selectedId]?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); } }, [selectedId]); - // Скачивание одной детали const handleDownload = (part: GeneratedPart) => { - const geometry = createBinGeometry( - part.width, - part.depth, - part.height, - config.wallThickness, - config.cornerRadius, - part.internalPartitions // <--- ВАЖНО для STL - ); + const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness, config.perforation); const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); exportSTL(mesh, `${part.name.replace(/\s+/g, '_')}.stl`); }; - // Скачивание всего архивом const handleDownloadAll = async () => { if (isZipping) return; setIsZipping(true); + try { + console.log("Starting ZIP generation..."); + if (typeof JSZip === 'undefined' && !JSZip) { + throw new Error("Библиотека JSZip не загружена."); + } + const zip = new JSZip(); + parts.forEach(part => { - const geometry = createBinGeometry( - part.width, - part.depth, - part.height, - config.wallThickness, - config.cornerRadius, - part.internalPartitions // <--- ВАЖНО для STL - ); + const geometry = createBinGeometry(part.width, part.depth, part.height, config.wallThickness, config.perforation); const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()); const stlData = generateSTL(mesh); zip.file(`${part.name.replace(/\s+/g, '_')}.stl`, stlData); }); + const content = await zip.generateAsync({ type: "blob" }); + const link = document.createElement('a'); link.href = URL.createObjectURL(content); link.download = "PrintFit_Project.zip"; document.body.appendChild(link); link.click(); document.body.removeChild(link); + } catch (e: any) { - alert(`Ошибка архивации: ${e.message}`); + console.error("Failed to create zip archive", e); + alert(`Ошибка при создании архива: ${e.message || 'Неизвестная ошибка'}`); } finally { setIsZipping(false); } }; - // Поделиться ссылкой - 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'); } - } catch (err) { - try { - const textArea = document.createElement("textarea"); - textArea.value = url; - textArea.style.position = "fixed"; - textArea.style.left = "-9999px"; - textArea.style.top = "0"; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - const result = document.execCommand('copy'); - document.body.removeChild(textArea); - if (result) success = true; - } catch (e) { console.error("Copy failed", e); } - } - if (success) { - setShareUrlCopied(true); - setTimeout(() => setShareUrlCopied(false), 3000); - } else { - prompt("Скопируйте ссылку вручную:", url); - } - }; - return ( -
- {/* Верхняя панель: Размеры + Поделиться */} -
- -
-
- - Размеры ящика: -
-
-
- Ширина: - {config.drawer.width} -
-
- Глубина: - {config.drawer.depth} -
-
- Высота: - {config.drawer.height} -
- мм -
-
- - -
- -
- {/* 3D Viewer */} -
-
-
- Управление -
-
    -
  • • ЛКМ: Вращение
  • -
  • • ПКМ: Перемещение
  • -
  • • Скролл: Масштаб
  • -
-
- - + {/* 3D Viewer */} +
+
+
+ Управление +
+
    +
  • • ЛКМ: Вращение
  • +
  • • ПКМ: Перемещение
  • +
  • • Скролл: Масштаб
  • +
  • • Клик по детали для выбора
  • +
+
+ + - - - - - -
- - - {parts.map(part => ( - setSelectedId(part.id)} - /> - ))} - -
- -
-
+ > + + + + + + + + +
+ + + {parts.map(part => ( + setSelectedId(part.id)} + /> + ))} + +
+ + +
+ +
+ + {/* Sidebar List */} +
+
+

+ Детали ({parts.length}) +

+
- {/* Sidebar List (Grid Layout) */} -
-
-

- Детали ({parts.length}) -

- -
- -
-
- {parts.map(part => ( -
{ itemRefs.current[part.id] = el }} - className={` - p-3 rounded-lg border transition-all cursor-pointer group flex flex-col gap-2 relative overflow-hidden - ${selectedId === part.id - ? 'bg-slate-800 border-accent shadow-md shadow-accent/10 ring-1 ring-accent' - : 'bg-slate-800/50 border-slate-700 hover:border-slate-500 hover:bg-slate-800' - } - `} - onClick={() => setSelectedId(part.id)} - > - {/* Индикатор цвета */} -
- - {/* Заголовок */} -
- - {part.name} - -
-
- - {/* Размеры */} -
- {part.width.toFixed(0)} × {part.depth.toFixed(0)} × {part.height.toFixed(0)} -
- - {/* Кнопка скачивания */} - -
- ))} -
-
+
+ {parts.map(part => ( +
{ itemRefs.current[part.id] = el }} + className={`p-4 rounded-lg border transition-all cursor-pointer group ${selectedId === part.id ? 'bg-slate-800 border-accent shadow-md shadow-accent/10 ring-1 ring-accent' : 'bg-slate-800/50 border-slate-700 hover:border-slate-500 hover:bg-slate-800'}`} + onClick={() => setSelectedId(part.id)} + > +
+ {part.name} +
+
+
+
+ Ширина + {part.width.toFixed(1)} +
+
+ Глубина + {part.depth.toFixed(1)} +
+
+ Высота + {part.height.toFixed(1)} +
+
+ +
+ ))}
diff --git a/src/services/geometryGenerator.ts b/src/services/geometryGenerator.ts index a16108a..9485a88 100644 --- a/src/services/geometryGenerator.ts +++ b/src/services/geometryGenerator.ts @@ -4,7 +4,6 @@ import { AppConfig, LayoutSplits, GeneratedPart, PerforationConfig } from '../ty /** * 1. Расчет списка ящиков на основе сетки - * Это создает массив отдельных коробочек, которые визуально образуют органайзер */ export const calculateParts = ( config: AppConfig, @@ -12,7 +11,7 @@ export const calculateParts = ( ): GeneratedPart[] => { const parts: GeneratedPart[] = []; - // Сортируем линии реза и добавляем границы (0 и 1) + // Сортируем линии реза const xPoints = [0, ...[...splits.x].sort((a, b) => a - b), 1]; const yPoints = [0, ...[...splits.y].sort((a, b) => a - b), 1]; @@ -21,26 +20,24 @@ export const calculateParts = ( for (let i = 0; i < xPoints.length - 1; i++) { for (let j = 0; j < yPoints.length - 1; j++) { - // Размеры текущей ячейки сетки const segmentX = xPoints[i] * config.drawer.width; const segmentY = yPoints[j] * config.drawer.depth; const segmentW = (xPoints[i + 1] - xPoints[i]) * config.drawer.width; const segmentD = (yPoints[j + 1] - yPoints[j]) * config.drawer.depth; - // Применяем толерантность (зазор между ящиками) - // Уменьшаем размер ящика, сдвигаем его к центру + // Применяем Tolerance (зазор) const realWidth = segmentW - config.printerTolerance; const realDepth = segmentD - config.printerTolerance; const realX = segmentX + (config.printerTolerance / 2); const realY = segmentY + (config.printerTolerance / 2); - // Защита от слишком мелких (фантомных) ячеек - if (realWidth < 2 || realDepth < 2) { + // Фильтр слишком мелких ячеек + if (realWidth < 1 || realDepth < 1) { continue; } parts.push({ - id: `part-${partCounter}-${Date.now()}`, // Уникальный ID + id: `part-${partCounter}`, name: `Ячейка ${i+1}-${j+1}`, width: realWidth, depth: realDepth, @@ -57,9 +54,7 @@ export const calculateParts = ( }; /** - * 2. Создание 2D профиля стены с отверстиями - * ВАЖНО: Контур стены -> CCW (Против часовой) - * ВАЖНО: Отверстия -> CW (По часовой) + * 2. Создание формы стены с отверстиями (алгоритм из архива) */ const createPerforatedWallShape = ( width: number, @@ -67,8 +62,7 @@ const createPerforatedWallShape = ( perf: PerforationConfig ): THREE.Shape => { const shape = new THREE.Shape(); - - // Внешний прямоугольник (Против часовой стрелки) + // Основной контур shape.moveTo(0, 0); shape.lineTo(width, 0); shape.lineTo(width, height); @@ -79,7 +73,7 @@ const createPerforatedWallShape = ( const { size, spacing, shape: type, border } = perf; - // Эффективная зона перфорации + // Эффективная зона const startX = border; const endX = width - border; const startY = border; @@ -87,22 +81,21 @@ const createPerforatedWallShape = ( if (startX >= endX || startY >= endY) return shape; - // Функция добавления одной дырки + const cellSize = size + spacing; + + // Хелпер добавления отверстия const addHole = (cx: number, cy: number) => { - // Проверка границ (центр отверстия не должен выходить за рамки) + // Проверка границ if (cx - size/2 < startX || cx + size/2 > endX || cy - size/2 < startY || cy + size/2 > endY) return; const holePath = new THREE.Path(); - const r = size / 2; if (type === 'circle') { - // aClockwise = true (По часовой стрелке) - holePath.absarc(cx, cy, r, 0, Math.PI * 2, true); + holePath.absarc(cx, cy, size / 2, 0, Math.PI * 2, true); } else if (type === 'hexagon') { - // Шестиугольник (По часовой стрелке) - // angle идет в минус: 90, 30, -30... + const r = size / 2; for (let k = 0; k < 6; k++) { - const angle = (-k * 60 + 90) * (Math.PI / 180); + const angle = (k * 60 + 30) * (Math.PI / 180); const px = cx + r * Math.cos(angle); const py = cy + r * Math.sin(angle); if (k === 0) holePath.moveTo(px, py); @@ -110,8 +103,8 @@ const createPerforatedWallShape = ( } holePath.closePath(); } else if (type === 'triangle') { - // Треугольник (По часовой стрелке) - const angles = [90, -30, 210]; // 90 -> -30 (CW) + const r = size / 2; + const angles = [90, 210, 330]; angles.forEach((deg, idx) => { const rad = deg * (Math.PI / 180); const px = cx + r * Math.cos(rad); @@ -127,24 +120,21 @@ const createPerforatedWallShape = ( // Генерация сетки if (type === 'hexagon') { - // Сотовая структура (смещенные ряды) - const hexWidth = size * 0.866; // sqrt(3)/2 + const hexHeight = size; + const hexWidth = size * 0.866; const colDist = hexWidth + spacing; - const rowDist = (size * 0.75) + spacing; + const rowDist = (hexHeight * 0.75) + spacing; - let rowIndex = 0; + let row = 0; for (let y = startY + size/2; y < endY; y += rowDist) { - const isOddRow = rowIndex % 2 === 1; - const offset = isOddRow ? colDist / 2 : 0; - + const offset = (row % 2) === 1 ? colDist / 2 : 0; for (let x = startX + size/2 + offset; x < endX; x += colDist) { addHole(x, y); } - rowIndex++; + row++; } } else { - // Обычная сетка (Круг, Треугольник) - const cellSize = size + spacing; + // Обычная сетка for (let x = startX + size/2; x < endX; x += cellSize) { for (let y = startY + size/2; y < endY; y += cellSize) { addHole(x, y); @@ -156,7 +146,7 @@ const createPerforatedWallShape = ( }; /** - * 3. Создание 3D геометрии для ОДНОГО ящика + * 3. Генерация 3D геометрии одного ящика */ export const createBinGeometry = ( width: number, @@ -168,7 +158,8 @@ export const createBinGeometry = ( const geometries: THREE.BufferGeometry[] = []; const perfConfig = perforation || { enabled: false, shape: 'circle', size: 0, spacing: 0, border: 0 }; - // 1. Пол (Всегда сплошной) + // 1. Пол - Всегда сплошной + // ВАЖНО: .toNonIndexed() нужен для корректного слияния с ExtrudeGeometry const floorGeo = new THREE.BoxGeometry(width, thickness, depth).toNonIndexed(); floorGeo.translate(0, thickness / 2, 0); geometries.push(floorGeo); @@ -182,58 +173,47 @@ export const createBinGeometry = ( }; // 2. Левая и Правая стенки (Полная глубина) - // Рисуем профиль (Ширина профиля = Глубине ящика) + // Рисуем профиль шириной = глубине ящика const lrShape = createPerforatedWallShape(depth, wallHeight, perfConfig); + // ВАЖНО: .toNonIndexed() const lrGeo = new THREE.ExtrudeGeometry(lrShape, extrudeSettings).toNonIndexed(); - // Центрируем геометрию для удобного вращения - lrGeo.center(); - - // Левая стенка (Left) - // Поворачиваем: Профиль лежит вдоль X -> поворот на 90 -> вдоль Z + // Left Wall const leftWall = lrGeo.clone(); - leftWall.rotateY(Math.PI / 2); - // Позиция: X = -width/2 + thickness/2, Y = пол + пол_стены - leftWall.translate(-(width/2) + thickness/2, thickness + wallHeight/2, 0); + leftWall.rotateY(-Math.PI / 2); + leftWall.translate(-(width/2) + thickness, thickness, -(depth/2)); geometries.push(leftWall); - // Правая стенка (Right) + // Right Wall const rightWall = lrGeo.clone(); - rightWall.rotateY(Math.PI / 2); - rightWall.translate((width/2) - thickness/2, thickness + wallHeight/2, 0); + rightWall.rotateY(-Math.PI / 2); + rightWall.translate((width/2), thickness, -(depth/2)); geometries.push(rightWall); - // 3. Передняя и Задняя стенки (Вставляются МЕЖДУ боковыми) - // Их ширина меньше на 2 толщины - const wallFBWidth = width - (2 * thickness); - + // 3. Передняя и Задняя стенки (Вставляются между боковыми) + // Ширина уменьшена на 2 толщины + const wallFBWidth = Math.max(0, width - (2 * thickness)); if (wallFBWidth > 0) { const fbShape = createPerforatedWallShape(wallFBWidth, wallHeight, perfConfig); const fbGeo = new THREE.ExtrudeGeometry(fbShape, extrudeSettings).toNonIndexed(); - - fbGeo.center(); - // Передняя стенка (Front) + // Front Wall const frontWall = fbGeo.clone(); - frontWall.translate(0, thickness + wallHeight/2, (depth/2) - thickness/2); + frontWall.translate(-(wallFBWidth/2), thickness, (depth/2) - thickness); geometries.push(frontWall); - // Задняя стенка (Back) + // Back Wall const backWall = fbGeo.clone(); - backWall.translate(0, thickness + wallHeight/2, -(depth/2) + thickness/2); + backWall.translate(-(wallFBWidth/2), thickness, -(depth/2)); geometries.push(backWall); } } - // Сливаем всё в один меш + // Слияние в один меш const merged = mergeBufferGeometries(geometries); - if (merged) merged.computeVertexNormals(); - return merged || new THREE.BoxGeometry(1, 1, 1).toNonIndexed(); }; -// --- ЭКСПОРТ (без изменений) --- - export const generateSTL = (mesh: THREE.Object3D): Uint8Array | string => { const exporter = new STLExporter(); const result = exporter.parse(mesh, { binary: true });