import React, { useState, useEffect, useRef } from 'react'; import * as THREE from 'three'; const LaserBoxGenerator = () => { // State for box parameters const [params, setParams] = useState({ width: 4, height: 3, depth: 2, thickness: 0.125, kerf: 0.007, units: 'inches', sheetWidth: 12, sheetHeight: 20, boxType: 'open', fingerWidth: 0.125 }); const [boxPieces, setBoxPieces] = useState([]); const [validationErrors, setValidationErrors] = useState([]); const [materialEfficiency, setMaterialEfficiency] = useState(0); const [isCalculated, setIsCalculated] = useState(false); const mountRef = useRef(null); const sceneRef = useRef(null); const rendererRef = useRef(null); // Initialize 3D scene useEffect(() => { if (!mountRef.current) return; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf0f0f0); const camera = new THREE.PerspectiveCamera(75, 400/300, 0.1, 1000); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(400, 300); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; mountRef.current.appendChild(renderer.domElement); // Lighting const ambientLight = new THREE.AmbientLight(0x404040, 0.6); scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(10, 10, 5); directionalLight.castShadow = true; scene.add(directionalLight); camera.position.set(8, 6, 8); camera.lookAt(0, 0, 0); sceneRef.current = scene; rendererRef.current = renderer; return () => { if (mountRef.current && renderer.domElement) { mountRef.current.removeChild(renderer.domElement); } renderer.dispose(); }; }, []); // Calculate finger joints const calculateFingers = (panelLength, materialThickness, minFingerWidth) => { const minFinger = Math.max(minFingerWidth, 2 * materialThickness); // For very small panels, allow smaller finger widths but ensure at least 3 fingers const effectiveMinFinger = Math.min(minFinger, panelLength / 3); const maxFingers = Math.floor(panelLength / effectiveMinFinger); if (maxFingers < 3) { // For very small panels, force 3 fingers if physically possible if (panelLength >= 3 * materialThickness) { const fingerWidth = panelLength / 3; return { count: 3, width: fingerWidth, valid: true, warning: fingerWidth < minFingerWidth ? `Finger width (${fingerWidth.toFixed(3)}") is smaller than target (${minFingerWidth}")` : null }; } return { error: "Panel too small for finger joints", count: 0, width: 0 }; } // Ensure odd number of fingers (start and end with finger) const fingerCount = maxFingers % 2 === 1 ? maxFingers : maxFingers - 1; const fingerWidth = panelLength / fingerCount; return { count: fingerCount, width: fingerWidth, valid: fingerWidth >= effectiveMinFinger, warning: fingerWidth < minFingerWidth ? `Finger width (${fingerWidth.toFixed(3)}") is smaller than target (${minFingerWidth}")` : null }; }; // Generate box pieces with kerf compensation const generateBoxPieces = () => { const { width, height, depth, thickness, kerf } = params; const pieces = []; const errors = []; // Validate minimum dimensions const minSize = Math.max(4 * thickness, 1.0); if (width < minSize || height < minSize || depth < minSize) { errors.push(`Minimum box dimension is ${minSize.toFixed(3)}" for current material thickness`); } // Calculate finger joints for each edge const widthFingers = calculateFingers(width, thickness, params.fingerWidth); const heightFingers = calculateFingers(height, thickness, params.fingerWidth); const depthFingers = calculateFingers(depth, thickness, params.fingerWidth); if (widthFingers.error) errors.push(`Width: ${widthFingers.error}`); if (heightFingers.error) errors.push(`Height: ${heightFingers.error}`); if (depthFingers.error) errors.push(`Depth: ${depthFingers.error}`); // Add warnings for small finger widths if (widthFingers.warning) errors.push(`Width: ${widthFingers.warning}`); if (heightFingers.warning) errors.push(`Height: ${heightFingers.warning}`); if (depthFingers.warning) errors.push(`Depth: ${depthFingers.warning}`); // Generate pieces with kerf compensation const kerfComp = kerf / 2; // Bottom panel pieces.push({ name: 'Bottom', width: Number(width + (2 * kerfComp)), height: Number(depth + (2 * kerfComp)), fingers: { top: { count: depthFingers.count, width: depthFingers.width, type: 'male' }, bottom: { count: depthFingers.count, width: depthFingers.width, type: 'male' }, left: { count: widthFingers.count, width: widthFingers.width, type: 'male' }, right: { count: widthFingers.count, width: widthFingers.width, type: 'male' } } }); // Front and back panels pieces.push({ name: 'Front', width: Number(width + (2 * kerfComp)), height: Number(height + (2 * kerfComp)), fingers: { bottom: { count: widthFingers.count, width: widthFingers.width, type: 'female' }, left: { count: heightFingers.count, width: heightFingers.width, type: 'male' }, right: { count: heightFingers.count, width: heightFingers.width, type: 'male' } } }); pieces.push({ name: 'Back', width: Number(width + (2 * kerfComp)), height: Number(height + (2 * kerfComp)), fingers: { bottom: { count: widthFingers.count, width: widthFingers.width, type: 'female' }, left: { count: heightFingers.count, width: heightFingers.width, type: 'male' }, right: { count: heightFingers.count, width: heightFingers.width, type: 'male' } } }); // Left and right panels (adjusted for material thickness) const adjustedDepth = depth - (2 * thickness); pieces.push({ name: 'Left', width: Number(adjustedDepth + (2 * kerfComp)), height: Number(height + (2 * kerfComp)), fingers: { bottom: { count: depthFingers.count, width: depthFingers.width, type: 'female' }, front: { count: heightFingers.count, width: heightFingers.width, type: 'female' }, back: { count: heightFingers.count, width: heightFingers.width, type: 'female' } } }); pieces.push({ name: 'Right', width: Number(adjustedDepth + (2 * kerfComp)), height: Number(height + (2 * kerfComp)), fingers: { bottom: { count: depthFingers.count, width: depthFingers.width, type: 'female' }, front: { count: heightFingers.count, width: heightFingers.width, type: 'female' }, back: { count: heightFingers.count, width: heightFingers.width, type: 'female' } } }); // Top panel (if closed box) if (params.boxType === 'closed') { pieces.push({ name: 'Top', width: Number(width + (2 * kerfComp)), height: Number(depth + (2 * kerfComp)), fingers: { front: { count: widthFingers.count, width: widthFingers.width, type: 'female' }, back: { count: widthFingers.count, width: widthFingers.width, type: 'female' }, left: { count: depthFingers.count, width: depthFingers.width, type: 'female' }, right: { count: depthFingers.count, width: depthFingers.width, type: 'female' } } }); } return { pieces, errors }; }; // Update 3D visualization const update3DVisualization = () => { if (!sceneRef.current || !rendererRef.current) return; // Clear existing geometry const objectsToRemove = []; sceneRef.current.traverse((child) => { if (child.isMesh && child.userData.isBoxPart) { objectsToRemove.push(child); } }); objectsToRemove.forEach(obj => sceneRef.current.remove(obj)); const { width, height, depth, thickness } = params; const material = new THREE.MeshLambertMaterial({ color: 0xdeb887, transparent: true, opacity: 0.8 }); // Create box visualization const createPanel = (w, h, t, x, y, z) => { const geometry = new THREE.BoxGeometry(w, h, t); const mesh = new THREE.Mesh(geometry, material); mesh.position.set(x, y, z); mesh.userData.isBoxPart = true; mesh.castShadow = true; mesh.receiveShadow = true; return mesh; }; // Bottom const bottom = createPanel(width, thickness, depth, 0, 0, 0); sceneRef.current.add(bottom); // Sides const front = createPanel(width, height, thickness, 0, height/2 + thickness/2, depth/2); const back = createPanel(width, height, thickness, 0, height/2 + thickness/2, -depth/2); const left = createPanel(thickness, height, depth - 2*thickness, -width/2, height/2 + thickness/2, 0); const right = createPanel(thickness, height, depth - 2*thickness, width/2, height/2 + thickness/2, 0); sceneRef.current.add(front, back, left, right); // Top (if closed) if (params.boxType === 'closed') { const top = createPanel(width, thickness, depth, 0, height + thickness, 0); sceneRef.current.add(top); } rendererRef.current.render(sceneRef.current, sceneRef.current.children.find(child => child.isCamera) || new THREE.PerspectiveCamera(75, 400/300, 0.1, 1000)); }; // Calculate material efficiency const calculateMaterialEfficiency = (pieces) => { const totalArea = pieces.reduce((sum, piece) => sum + (Number(piece.width) * Number(piece.height)), 0); const sheetArea = params.sheetWidth * params.sheetHeight; return Math.min((totalArea / sheetArea) * 100, 100); }; // Generate SVG for laser cutting const generateSVG = () => { const { pieces } = generateBoxPieces(); let svgContent = ` '; return svgContent; }; // Download SVG file const downloadSVG = () => { const svgContent = generateSVG(); const blob = new Blob([svgContent], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `laser-cut-box-${params.width}x${params.height}x${params.depth}.svg`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; // Manual calculation trigger const calculateDimensions = () => { const result = generateBoxPieces(); setBoxPieces(result.pieces); setValidationErrors(result.errors); setMaterialEfficiency(calculateMaterialEfficiency(result.pieces)); setIsCalculated(true); update3DVisualization(); }; // Update calculations when parameters change useEffect(() => { // Reset calculation state when parameters change setIsCalculated(false); setBoxPieces([]); setValidationErrors([]); setMaterialEfficiency(0); // Clear 3D visualization if (sceneRef.current) { const objectsToRemove = []; sceneRef.current.traverse((child) => { if (child.isMesh && child.userData.isBoxPart) { objectsToRemove.push(child); } }); objectsToRemove.forEach(obj => sceneRef.current.remove(obj)); if (rendererRef.current) { rendererRef.current.render(sceneRef.current, sceneRef.current.children.find(child => child.isCamera) || new THREE.PerspectiveCamera(75, 400/300, 0.1, 1000)); } } }, [params]); const handleParamChange = (key, value) => { const numericValue = parseFloat(value); setParams(prev => ({ ...prev, [key]: isNaN(numericValue) ? value : numericValue })); }; return (
Design custom boxes with precise kerf compensation for laser cutting
Click "Calculate Dimensions" to see 3D preview
) : (Interactive 3D preview of your assembled box
)}Calculate dimensions to see cut list
Parametric Laser-Cut Box Generator v1.0 | Kerf compensation: {params.kerf}" | Material: {params.thickness}"