diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..209cf7f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/uploads +/examples/output \ No newline at end of file diff --git a/examples/api-example-multiple.js b/examples/api-example-multiple.js new file mode 100644 index 00000000..11cdc6a5 --- /dev/null +++ b/examples/api-example-multiple.js @@ -0,0 +1,185 @@ +import fetch from 'node-fetch'; +import fs from 'fs/promises'; +import FormData from 'form-data'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +/** + * SVGnest API 使用示例 - 多个SVG文件版本 + * 这个版本分别上传容器和零件的SVG文件 + */ + +// 获取当前文件的目录路径 +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// 使用fetch调用API(在Node.js环境中) +async function runNestingWithMultipleSvgFiles(binFilePath, partsFilePath, config = {}) { + try { + // 确保使用绝对路径 + const absoluteBinPath = path.isAbsolute(binFilePath) ? binFilePath : path.resolve(__dirname, binFilePath); + const absolutePartsPath = path.isAbsolute(partsFilePath) ? partsFilePath : path.resolve(__dirname, partsFilePath); + + console.log(`读取bin文件: ${absoluteBinPath}`); + console.log(`读取parts文件: ${absolutePartsPath}`); + + // 读取SVG文件 + const binFile = await fs.readFile(absoluteBinPath); + const partsFile = await fs.readFile(absolutePartsPath); + + // 创建FormData + const formData = new FormData(); + formData.append('binFile', binFile, { + filename: path.basename(absoluteBinPath), + contentType: 'image/svg+xml', + }); + formData.append('partsFile', partsFile, { + filename: path.basename(absolutePartsPath), + contentType: 'image/svg+xml', + }); + + // 添加配置参数 + if (config.spacing) formData.append('spacing', config.spacing.toString()); + if (config.rotations) formData.append('rotations', config.rotations.toString()); + if (config.populationSize) formData.append('populationSize', config.populationSize.toString()); + if (config.mutationRate) formData.append('mutationRate', config.mutationRate.toString()); + if (config.useHoles) formData.append('useHoles', config.useHoles.toString()); + if (config.exploreConcave) formData.append('exploreConcave', config.exploreConcave.toString()); + + // 第一步:创建嵌套任务(上传SVG文件) + console.log('上传bin文件和parts文件...'); + const createResponse = await fetch('http://localhost:3000/api/jobs/upload-multiple', { + method: 'POST', + body: formData, + headers: formData.getHeaders(), + }); + + const createResult = await createResponse.json(); + + if (!createResponse.ok) { + throw new Error(`创建任务失败: ${createResult.error || createResponse.statusText}`); + } + + const { jobId } = createResult; + console.log(`创建任务成功,ID: ${jobId}`); + console.log(`容器ID: ${createResult.bin.id}`); + console.log(`零件数量: ${createResult.partsCount}`); + + // 第二步:启动嵌套计算 + console.log('启动嵌套计算...'); + const startResponse = await fetch(`http://localhost:3000/api/jobs/${jobId}/start`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + iterations: 20 + }) + }); + + if (!startResponse.ok) { + const startError = await startResponse.json(); + throw new Error(`启动任务失败: ${startError.error || startResponse.statusText}`); + } + + console.log(`已启动任务: ${jobId}`); + + // 第三步:轮询任务状态 + let status = 'processing'; + while (status === 'processing') { + await new Promise(resolve => setTimeout(resolve, 1000)); // 等待1秒 + + const statusResponse = await fetch(`http://localhost:3000/api/jobs/${jobId}`); + const jobStatus = await statusResponse.json(); + + console.log(`任务进度: ${Math.round(jobStatus.progress * 100)}%`); + status = jobStatus.status; + } + + // 第四步:获取结果 + if (status === 'completed') { + const resultResponse = await fetch(`http://localhost:3000/api/jobs/${jobId}/result`); + const { result } = await resultResponse.json(); + + console.log('嵌套完成!'); + console.log(`利用率: ${(result.utilization * 100).toFixed(2)}%`); + console.log('零件放置情况:'); + + // 添加空数组检查,避免forEach错误 + if (result.placements && Array.isArray(result.placements)) { + result.placements.forEach(placement => { + console.log(`- 零件 ${placement.partId}: 位置 (${placement.x.toFixed(2)}, ${placement.y.toFixed(2)}), 旋转 ${placement.rotation}°`); + }); + + // 下载SVG文件 + await downloadSVG(jobId); + } else { + console.log('没有返回零件放置信息'); + } + + // 显示未放置的零件 + if (result.unplacedParts && result.unplacedParts.length > 0) { + console.log('未放置的零件:'); + result.unplacedParts.forEach(part => { + console.log(`- 零件 ${part.id || part}`); + }); + } + + return result; + } else { + console.error(`任务失败,状态: ${status}`); + return null; + } + } catch (error) { + console.error('执行嵌套出错:', error); + throw error; + } +} + +/** + * 从服务器下载SVG文件 + * @param {string} jobId - 任务ID + */ +async function downloadSVG(jobId) { + try { + console.log('从服务器下载SVG文件...'); + + const svgResponse = await fetch(`http://localhost:3000/api/jobs/${jobId}/svg`); + + if (!svgResponse.ok) { + throw new Error(`下载SVG失败: ${svgResponse.status} ${svgResponse.statusText}`); + } + + const svgContent = await svgResponse.text(); + + // 保存到文件 + const outputDir = path.resolve(__dirname, 'output'); + + // Ensure output directory exists + try { + await fs.access(outputDir); + } catch (err) { + await fs.mkdir(outputDir, { recursive: true }); + } + + const filename = path.join(outputDir, `nesting-result-${jobId}.svg`); + await fs.writeFile(filename, svgContent); + console.log(`SVG文件已下载并保存为: ${filename}`); + } catch (error) { + console.error('下载SVG出错:', error); + } +} + +// 执行示例 +const binFilePath = process.argv[2] || './bin.svg'; +const partsFilePath = process.argv[3] || './parts.svg'; + +console.log(`使用bin文件: ${binFilePath}`); +console.log(`使用parts文件: ${partsFilePath}`); + +runNestingWithMultipleSvgFiles(binFilePath, partsFilePath, { + spacing: 5, + rotations: 4, + useHoles: true +}).catch(console.error); diff --git a/examples/bin.svg b/examples/bin.svg new file mode 100644 index 00000000..687c10a0 --- /dev/null +++ b/examples/bin.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/examples/parts.svg b/examples/parts.svg new file mode 100644 index 00000000..ae373ca4 --- /dev/null +++ b/examples/parts.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 00000000..dac6d32a --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "svgnest-api", + "version": "1.0.0", + "description": "SVGnest API for optimal nesting of parts", + "main": "server.js", + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "example-multiple": "node examples/api-example-multiple.js", + "init-examples": "node src/scripts/init-examples.js", + "validate-svg": "node src/scripts/validate-svg.js" + }, + "dependencies": { + "body-parser": "^1.19.0", + "cors": "^2.8.5", + "express": "^4.17.1", + "form-data": "^4.0.0", + "js-clipper": "^1.0.1", + "multer": "^2.0.2", + "node-fetch": "^2.6.7", + "uuid": "^8.3.2", + "xmldom": "^0.6.0" + }, + "devDependencies": { + "nodemon": "^2.0.14" + } +} diff --git a/server.js b/server.js new file mode 100644 index 00000000..a10cb066 --- /dev/null +++ b/server.js @@ -0,0 +1,615 @@ +import express from 'express'; +import bodyParser from 'body-parser'; +import cors from 'cors'; +import { v4 as uuidv4 } from 'uuid'; +import path from 'path'; +import fs from 'fs'; +import multer from 'multer'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +// Get current file path (ES module equivalent of __dirname) +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Import our modified SVGnest module and SVG parser +import SVGnest from './src/svgnest-module.js'; +import SvgParser from './src/svgparser-module.js'; + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadDir = path.join(__dirname, 'uploads'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + cb(null, `${uuidv4()}${path.extname(file.originalname)}`); + } +}); + +const upload = multer({ + storage, + fileFilter: (req, file, cb) => { + if (file.mimetype === 'image/svg+xml' || + file.mimetype === 'text/xml' || + file.mimetype === 'application/xml' || + path.extname(file.originalname).toLowerCase() === '.svg') { + cb(null, true); + } else { + cb(new Error('Only SVG files are allowed')); + } + } +}); + +// Middleware +app.use(cors()); +app.use(bodyParser.json({ limit: '50mb' })); +app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); + +// In-memory storage for jobs +const jobs = {}; + +// Parse SVG file and extract parts and bin +function parseSvgFile(filePath, binId) { + try { + const svgString = fs.readFileSync(filePath, 'utf8'); + const svgParser = new SvgParser(); + + // Parse SVG and clean it + svgParser.load(svgString); + const svg = svgParser.cleanInput(); + + // Extract polygons from SVG + const allParts = []; + for (let i = 0; i < svg.childNodes.length; i++) { + const node = svg.childNodes[i]; + if (node.tagName && ['path', 'polygon', 'rect', 'circle', 'ellipse'].includes(node.tagName.toLowerCase())) { + const polygon = svgParser.polygonify(node); + if (polygon && polygon.length > 0) { + allParts.push({ + id: `part${i}`, + type: 'polygon', + points: polygon.map(point => [point.x, point.y]), + originalNode: node.outerHTML + }); + } + } + } + + if (allParts.length === 0) { + throw new Error('No valid parts found in the SVG'); + } + + // If binId is specified, use that part as the bin + let bin = null; + let parts = allParts; + + if (binId !== undefined && binId !== null) { + if (binId < 0 || binId >= allParts.length) { + throw new Error(`Invalid bin ID: ${binId}`); + } + bin = allParts[binId]; + parts = allParts.filter((_, index) => index !== binId); + } else { + // By default, use the first part as the bin + bin = allParts[0]; + parts = allParts.slice(1); + } + + return { bin, parts }; + } catch (error) { + console.error('Error parsing SVG:', error); + throw error; + } +} + +// API Routes +app.post('/api/jobs/upload', upload.single('svgFile'), (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No SVG file uploaded' }); + } + + const jobId = uuidv4(); + const binId = req.body.binId ? parseInt(req.body.binId) : 0; + const filePath = req.file.path; + + // Parse the SVG file + const { bin, parts } = parseSvgFile(filePath, binId); + + // Create configuration + const config = { + spacing: req.body.spacing ? parseFloat(req.body.spacing) : 0, + rotations: req.body.rotations ? parseInt(req.body.rotations) : 4, + populationSize: req.body.populationSize ? parseInt(req.body.populationSize) : 10, + mutationRate: req.body.mutationRate ? parseInt(req.body.mutationRate) : 10, + useHoles: req.body.useHoles === 'true', + exploreConcave: req.body.exploreConcave === 'true' + }; + + // Create a new job + jobs[jobId] = { + id: jobId, + svgFilePath: filePath, + bin, + parts, + config, + status: 'created', + progress: 0, + result: null, + createdAt: new Date() + }; + + return res.status(201).json({ + jobId, + status: 'created', + bin: { + id: bin.id, + type: bin.type + }, + partsCount: parts.length + }); + } catch (error) { + console.error('Error creating job:', error); + return res.status(500).json({ error: 'Failed to create job: ' + error.message }); + } +}); + +// 新增:同时上传bin和parts的svg文件 +app.post('/api/jobs/upload-multiple', upload.fields([ + { name: 'binFile', maxCount: 1 }, + { name: 'partsFile', maxCount: 1 } +]), (req, res) => { + try { + if (!req.files || !req.files.binFile || !req.files.partsFile) { + return res.status(400).json({ error: 'Both bin SVG file and parts SVG file are required' }); + } + + const jobId = uuidv4(); + const binFilePath = req.files.binFile[0].path; + const partsFilePath = req.files.partsFile[0].path; + + // 解析bin文件 + console.log(`Processing bin file: ${binFilePath}`); + const binSvgString = fs.readFileSync(binFilePath, 'utf8'); + console.log(`Bin SVG content length: ${binSvgString.length}`); + + const binParser = new SvgParser(); + binParser.load(binSvgString); + const binSvg = binParser.cleanInput(); + + // 提取bin形状 + let bin = null; + console.log(`Number of child nodes in bin SVG: ${binSvg.childNodes.length}`); + + for (let i = 0; i < binSvg.childNodes.length; i++) { + const node = binSvg.childNodes[i]; + console.log(`Node ${i}: ${node.tagName || 'no tagName'}`); + + if (node.tagName && ['path', 'polygon', 'polyline', 'rect', 'circle', 'ellipse'].includes(node.tagName.toLowerCase())) { + console.log(`Processing node with tag: ${node.tagName.toLowerCase()}`); + + // For rect elements, create polygon manually if polygonify fails + if (node.tagName.toLowerCase() === 'rect') { + try { + const x = parseFloat(node.getAttribute('x') || 0); + const y = parseFloat(node.getAttribute('y') || 0); + const width = parseFloat(node.getAttribute('width')); + const height = parseFloat(node.getAttribute('height')); + + console.log(`Rect attributes: x=${x}, y=${y}, width=${width}, height=${height}`); + + // Create polygon points for rectangle + const points = [ + { x, y }, + { x: x + width, y }, + { x: x + width, y: y + height }, + { x, y: y + height } + ]; + + bin = { + id: 'bin', + type: 'polygon', + points: points.map(point => [point.x, point.y]), + originalNode: node.outerHTML + }; + + console.log(`Manually created polygon for rect with ${points.length} points`); + break; + } catch (rectError) { + console.error('Error creating polygon from rect:', rectError); + } + } + + // Try regular polygonify for all elements + try { + const polygon = binParser.polygonify(node); + console.log(`Polygon result: ${polygon ? polygon.length + ' points' : 'null'}`); + + if (polygon && polygon.length > 0) { + bin = { + id: 'bin', + type: 'polygon', + points: polygon.map(point => [point.x, point.y]), + originalNode: node.outerHTML + }; + console.log(`Created bin with ${polygon.length} points`); + break; + } + } catch (polyError) { + console.error('Error in polygonify:', polyError); + } + } + } + + if (!bin) { + throw new Error('No valid bin shape found in the bin SVG file'); + } + + // 解析parts文件 + const partsSvgString = fs.readFileSync(partsFilePath, 'utf8'); + const partsParser = new SvgParser(); + partsParser.load(partsSvgString); + const partsSvg = partsParser.cleanInput(); + + // 提取parts形状 + const parts = []; + for (let i = 0; i < partsSvg.childNodes.length; i++) { + const node = partsSvg.childNodes[i]; + if (node.tagName && ['path', 'polygon', 'rect', 'circle', 'ellipse'].includes(node.tagName.toLowerCase())) { + const polygon = partsParser.polygonify(node); + if (polygon && polygon.length > 0) { + parts.push({ + id: `part${i}`, + type: 'polygon', + points: polygon.map(point => [point.x, point.y]), + originalNode: node.outerHTML + }); + } + } + } + + if (parts.length === 0) { + throw new Error('No valid parts found in the parts SVG file'); + } + + // 创建配置 + const config = { + spacing: req.body.spacing ? parseFloat(req.body.spacing) : 0, + rotations: req.body.rotations ? parseInt(req.body.rotations) : 4, + populationSize: req.body.populationSize ? parseInt(req.body.populationSize) : 10, + mutationRate: req.body.mutationRate ? parseInt(req.body.mutationRate) : 10, + useHoles: req.body.useHoles === 'true', + exploreConcave: req.body.exploreConcave === 'true' + }; + + // 创建新任务 + jobs[jobId] = { + id: jobId, + binFilePath: binFilePath, + partsFilePath: partsFilePath, + bin, + parts, + config, + status: 'created', + progress: 0, + result: null, + createdAt: new Date() + }; + + return res.status(201).json({ + jobId, + status: 'created', + bin: { + id: bin.id, + type: bin.type + }, + partsCount: parts.length + }); + } catch (error) { + console.error('Error creating job:', error); + return res.status(500).json({ error: 'Failed to create job: ' + error.message }); + } +}); + +// Original job creation endpoint - keep for backward compatibility +app.post('/api/jobs', (req, res) => { + try { + const jobId = uuidv4(); + const { bin, parts, config } = req.body; + + if (!bin || !parts) { + return res.status(400).json({ error: 'Missing bin or parts data' }); + } + + // Create a new job + jobs[jobId] = { + id: jobId, + bin, + parts, + config: config || {}, + status: 'created', + progress: 0, + result: null, + createdAt: new Date() + }; + + return res.status(201).json({ jobId, status: 'created' }); + } catch (error) { + console.error('Error creating job:', error); + return res.status(500).json({ error: 'Failed to create job' }); + } +}); + +app.post('/api/jobs/:jobId/start', async (req, res) => { + try { + const { jobId } = req.params; + const { iterations = 10 } = req.body; + + if (!jobs[jobId]) { + return res.status(404).json({ error: 'Job not found' }); + } + + const job = jobs[jobId]; + job.status = 'processing'; + + // Start nesting in the background + SVGnest.setConfig(job.config); + + // Set up progress callback + SVGnest.setProgressCallback((progress) => { + job.progress = progress; + }); + + // Run nesting asynchronously + setTimeout(async () => { + try { + const result = await SVGnest.nest(job.bin, job.parts, iterations); + job.result = result; + job.status = 'completed'; + } catch (error) { + job.status = 'failed'; + job.error = error.message; + } + }, 0); + + return res.json({ jobId, status: job.status }); + } catch (error) { + console.error('Error starting job:', error); + return res.status(500).json({ error: 'Failed to start job' }); + } +}); + +app.get('/api/jobs/:jobId', (req, res) => { + const { jobId } = req.params; + + if (!jobs[jobId]) { + return res.status(404).json({ error: 'Job not found' }); + } + + return res.json(jobs[jobId]); +}); + +app.get('/api/jobs/:jobId/result', (req, res) => { + const { jobId } = req.params; + + if (!jobs[jobId]) { + return res.status(404).json({ error: 'Job not found' }); + } + + const job = jobs[jobId]; + + if (job.status !== 'completed') { + return res.status(400).json({ + error: 'Job result not available', + status: job.status, + progress: job.progress + }); + } + + return res.json({ + jobId, + result: job.result + }); +}); + +// 新增:返回SVG格式的嵌套结果 +app.get('/api/jobs/:jobId/svg', (req, res) => { + const { jobId } = req.params; + + if (!jobs[jobId]) { + return res.status(404).json({ error: 'Job not found' }); + } + + const job = jobs[jobId]; + + if (job.status !== 'completed') { + return res.status(400).json({ + error: 'Job result not available', + status: job.status + }); + } + + try { + // 生成SVG + const svgContent = generateSVG(job.bin, job.parts, job.result); + + // 设置响应头 + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Content-Disposition', `attachment; filename="nesting-result-${jobId}.svg"`); + + // 返回SVG内容 + return res.send(svgContent); + } catch (error) { + console.error('Error generating SVG:', error); + return res.status(500).json({ error: 'Failed to generate SVG' }); + } +}); + +// SVG生成函数 +function generateSVG(bin, parts, result) { + // Get dimensions from bin + const binPoints = bin.points || []; + let minX = Number.MAX_VALUE, minY = Number.MAX_VALUE; + let maxX = Number.MIN_VALUE, maxY = Number.MIN_VALUE; + + binPoints.forEach(point => { + minX = Math.min(minX, point[0]); + minY = Math.min(minY, point[1]); + maxX = Math.max(maxX, point[0]); + maxY = Math.max(maxY, point[1]); + }); + + const width = maxX - minX || 800; + const height = maxY - minY || 600; + + // Create SVG header + let svgContent = ` + + + + + + `; + + // Add placed parts + if (result.placements && Array.isArray(result.placements)) { + result.placements.forEach(placement => { + // Find the corresponding part + const part = parts.find(p => p.id === placement.partId); + + if (part && part.points) { + // Generate SVG from original node if available, otherwise generate from points + if (part.originalNode) { + const rotationTransform = placement.rotation ? ` rotate(${placement.rotation})` : ''; + svgContent += ` + + ${part.originalNode} + `; + } else { + // Generate path data with rotation and position + const pathData = generatePathWithTransform(part.points, placement.x, placement.y, placement.rotation); + + // Add to SVG with random color + const randomColor = '#' + Math.floor(Math.random()*16777215).toString(16); + svgContent += ` + + Part ${placement.partId} + `; + } + } + }); + } + + // Add unplaced parts section + if (result.unplacedParts && result.unplacedParts.length > 0) { + svgContent += ` + + + + + Unplaced Parts:`; + + let offsetX = 10; + const offsetY = 20; + + result.unplacedParts.forEach((unplacedPartId, index) => { + // Find the corresponding part + const partId = typeof unplacedPartId === 'string' ? unplacedPartId : unplacedPartId.id; + const part = parts.find(p => p.id === partId); + + if (part && part.points) { + // Calculate part bounds + let partMinX = Number.MAX_VALUE, partMinY = Number.MAX_VALUE; + let partMaxX = Number.MIN_VALUE, partMaxY = Number.MIN_VALUE; + + part.points.forEach(point => { + partMinX = Math.min(partMinX, point[0]); + partMinY = Math.min(partMinY, point[1]); + partMaxX = Math.max(partMaxX, point[0]); + partMaxY = Math.max(partMaxY, point[1]); + }); + + const partWidth = partMaxX - partMinX; + const partHeight = partMaxY - partMinY; + + // Generate path data + const pathData = `M${part.points.map(point => + `${point[0] - partMinX + offsetX},${point[1] - partMinY + offsetY}` + ).join(' L')} Z`; + + // Add to SVG + svgContent += ` + + Unplaced Part ${partId} + + ${partId}`; + + // Update offset for next part + offsetX += partWidth + 20; + } + }); + } + + // Add utilization info + svgContent += ` + + + + + Material Utilization: ${(result.utilization * 100).toFixed(2)}% + +`; + + return svgContent; +} + +// Generate SVG path with transform +function generatePathWithTransform(points, translateX, translateY, rotation) { + if (!points || points.length === 0) return ''; + + const transformedPoints = points.map(point => { + const [x, y] = point; + + // Apply rotation if needed + let newX = x; + let newY = y; + + if (rotation) { + const rad = rotation * Math.PI / 180; + newX = x * Math.cos(rad) - y * Math.sin(rad); + newY = x * Math.sin(rad) + y * Math.cos(rad); + } + + // Apply translation + newX += translateX; + newY += translateY; + + return `${newX},${newY}`; + }); + + return `M${transformedPoints.join(' L')} Z`; +} + +app.get('/api/jobs', (req, res) => { + const jobList = Object.values(jobs).map(job => ({ + id: job.id, + status: job.status, + progress: job.progress, + createdAt: job.createdAt + })); + + return res.json(jobList); +}); + +// Start server +app.listen(PORT, () => { + console.log(`SVGnest API server running on port ${PORT}`); +}); diff --git a/src/geometryutil-module.js b/src/geometryutil-module.js new file mode 100644 index 00000000..daded0dc --- /dev/null +++ b/src/geometryutil-module.js @@ -0,0 +1,20 @@ +// Geometry utility functions for SVG processing + +const GeometryUtil = { + // Example methods - implement based on your needs + pointDistance(p1, p2) { + return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + }, + + pointInPolygon(point, polygon) { + // Implementation of point-in-polygon check + }, + + polygonArea(polygon) { + // Implementation of polygon area calculation + } + + // Add other geometric utility functions as needed +}; + +export default GeometryUtil; diff --git a/src/lib/clipper.js b/src/lib/clipper.js new file mode 100644 index 00000000..defb5674 --- /dev/null +++ b/src/lib/clipper.js @@ -0,0 +1,43 @@ +/** + * ClipperLib适配器 + * 为Node.js环境提供ClipperLib功能 + */ + +// 注意:实际使用时需要安装js-clipper依赖: +// npm install js-clipper --save + +// 简化版实现,演示如何替代ClipperLib +const ClipperLib = { + Clipper: { + // 计算多边形面积 + Area: function(path) { + if (!path || path.length < 3) return 0; + + let area = 0; + for (let i = 0, j = path.length - 1; i < path.length; j = i++) { + area += (path[j].X + path[i].X) * (path[j].Y - path[i].Y); + } + + return Math.abs(area / 2); + }, + + // 检查多边形是否简单 + IsSimple: function(path) { + // 简化实现 + return true; + }, + + // 清理多边形(移除小于容差的细节) + CleanPolygon: function(path, distance = 1.415) { + return path; + }, + + // 计算两个多边形的最小距离 + MinkowskiDiff: function(poly1, poly2) { + // 简化实现 + return []; + } + } +}; + +export default ClipperLib; diff --git a/src/matrix-module.js b/src/matrix-module.js new file mode 100644 index 00000000..44e14ade --- /dev/null +++ b/src/matrix-module.js @@ -0,0 +1,60 @@ +// Matrix operations for SVG transformations + +class Matrix { + constructor(a, b, c, d, e, f) { + this.a = a !== undefined ? a : 1; + this.b = b || 0; + this.c = c || 0; + this.d = d !== undefined ? d : 1; + this.e = e || 0; + this.f = f || 0; + } + + // Matrix multiplication + multiply(matrix) { + const a = this.a * matrix.a + this.c * matrix.b; + const c = this.a * matrix.c + this.c * matrix.d; + const e = this.a * matrix.e + this.c * matrix.f + this.e; + + const b = this.b * matrix.a + this.d * matrix.b; + const d = this.b * matrix.c + this.d * matrix.d; + const f = this.b * matrix.e + this.d * matrix.f + this.f; + + this.a = a; + this.b = b; + this.c = c; + this.d = d; + this.e = e; + this.f = f; + + return this; + } + + // Apply matrix to point + transformPoint(x, y) { + return { + x: x * this.a + y * this.c + this.e, + y: x * this.b + y * this.d + this.f + }; + } + + // Create rotation matrix + static rotate(angle) { + const rad = angle * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return new Matrix(cos, sin, -sin, cos, 0, 0); + } + + // Create translation matrix + static translate(x, y) { + return new Matrix(1, 0, 0, 1, x, y); + } + + // Create scaling matrix + static scale(sx, sy) { + return new Matrix(sx, 0, 0, sy, 0, 0); + } +} + +export { Matrix }; diff --git a/src/readme.md b/src/readme.md new file mode 100644 index 00000000..cdc6667a --- /dev/null +++ b/src/readme.md @@ -0,0 +1,14 @@ +## 生成svg测试文件 +npm run init-examples +会生成在example文件夹内 + +## 校验svg是否有效 +npm run validate-svg [文件路径] +如 +npm run validate-svg examples/bin.svg +npm run validate-svg examples/parts.svg + +## 执行API全流程脚本 +npm run example-multiple + +上传的图片在uploads文件夹下,生成的图片在examples/output文件夹下 \ No newline at end of file diff --git a/src/scripts/init-examples.js b/src/scripts/init-examples.js new file mode 100644 index 00000000..bd4b4ecb --- /dev/null +++ b/src/scripts/init-examples.js @@ -0,0 +1,73 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// 项目根目录 +const projectRoot = path.resolve(__dirname, '..'); +const examplesDir = path.resolve(projectRoot, 'examples'); + +// 确保examples目录存在 +async function ensureDirectoryExists(dirPath) { + try { + await fs.access(dirPath); + } catch (error) { + console.log(`创建目录: ${dirPath}`); + await fs.mkdir(dirPath, { recursive: true }); + } +} + +// bin.svg 文件内容 +const binSvgContent = ` + + + +`; + +// parts.svg 文件内容 +const partsSvgContent = ` + + + + + + + + + + + + + + + + + + +`; + +async function init() { + try { + // 确保目录存在 + await ensureDirectoryExists(examplesDir); + + // 写入示例SVG文件 + const binSvgPath = path.join(examplesDir, 'bin.svg'); + const partsSvgPath = path.join(examplesDir, 'parts.svg'); + + console.log(`创建bin.svg文件: ${binSvgPath}`); + await fs.writeFile(binSvgPath, binSvgContent); + + console.log(`创建parts.svg文件: ${partsSvgPath}`); + await fs.writeFile(partsSvgPath, partsSvgContent); + + console.log('示例文件初始化完成'); + } catch (error) { + console.error('初始化示例文件时出错:', error); + } +} + +init(); diff --git a/src/scripts/validate-svg.js b/src/scripts/validate-svg.js new file mode 100644 index 00000000..d6f8bf9c --- /dev/null +++ b/src/scripts/validate-svg.js @@ -0,0 +1,93 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import SvgParser from '../svgparser-module.js'; + +// Get the current directory +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Validates an SVG file by parsing it and extracting shapes + * @param {string} filePath - Path to the SVG file + */ +async function validateSvgFile(filePath) { + try { + console.log(`Validating SVG file: ${filePath}`); + const svgContent = await fs.readFile(filePath, 'utf8'); + console.log(`File size: ${svgContent.length} bytes`); + + const parser = new SvgParser(); + parser.load(svgContent); + const svg = parser.cleanInput(); + + console.log(`SVG has ${svg.childNodes.length} child nodes`); + + // Check each node for valid shapes + let validShapesFound = 0; + + for (let i = 0; i < svg.childNodes.length; i++) { + const node = svg.childNodes[i]; + if (!node.tagName) { + console.log(`Node ${i}: No tagName`); + continue; + } + + console.log(`Node ${i}: ${node.tagName}`); + + // Check for supported element types + if (['path', 'polygon', 'polyline', 'rect', 'circle', 'ellipse'].includes(node.tagName.toLowerCase())) { + try { + const polygon = parser.polygonify(node); + if (polygon && polygon.length > 0) { + validShapesFound++; + console.log(`✅ Valid shape found: ${node.tagName} with ${polygon.length} points`); + } else { + console.log(`❌ Invalid shape: ${node.tagName} (no points generated)`); + } + } catch (error) { + console.error(`❌ Error processing ${node.tagName}:`, error); + } + } else { + console.log(`⚠️ Unsupported element type: ${node.tagName}`); + } + } + + console.log(`\nValidation summary for ${path.basename(filePath)}:`); + console.log(`Total nodes: ${svg.childNodes.length}`); + console.log(`Valid shapes found: ${validShapesFound}`); + + if (validShapesFound === 0) { + console.log('❌ No valid shapes found in this SVG file'); + } else { + console.log('✅ SVG file contains valid shapes'); + } + + return validShapesFound > 0; + } catch (error) { + console.error(`Error validating SVG file:`, error); + return false; + } +} + +// Run the validation if this script is executed directly +const filePath = process.argv[2]; +if (filePath) { + validateSvgFile(path.resolve(filePath)) + .then(isValid => { + if (!isValid) { + console.log('SVG validation failed'); + process.exit(1); + } + }) + .catch(error => { + console.error('Error:', error); + process.exit(1); + }); +} else { + console.log('Usage: node validate-svg.js '); + process.exit(1); +} + +export default validateSvgFile; diff --git a/src/svgnest-core.js b/src/svgnest-core.js new file mode 100644 index 00000000..5659d5da --- /dev/null +++ b/src/svgnest-core.js @@ -0,0 +1,628 @@ +/** + * SVGnest核心模块 - 从前端代码改造成Node.js环境可用的模块 + * 实现原始SVGnest的核心嵌套算法 + */ +import { DOMParser } from 'xmldom'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import ClipperLib from './lib/clipper.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// 模拟浏览器环境中的对象 +const mockDocument = { + createElementNS: (ns, tagName) => { + return { + tagName, + setAttribute: () => {}, + getAttribute: () => {}, + appendChild: () => {}, + childNodes: [], + style: {} + }; + } +}; + +class SVGnestCore { + constructor() { + // 配置参数 + this.config = { + clipperScale: 10000000, + curveTolerance: 0.3, + spacing: 0, + rotations: 4, + populationSize: 10, + mutationRate: 10, + useHoles: true, + exploreConcave: false + }; + + this.progressCallback = null; + + // NFP缓存 + this.nfpCache = {}; + + // 绕过浏览器特定API的工作区 + this.workingDoc = mockDocument; + } + + /** + * 设置配置参数 + */ + setConfig(config) { + this.config = {...this.config, ...config}; + return this; + } + + /** + * 设置进度回调函数 + */ + setProgressCallback(callback) { + this.progressCallback = callback; + return this; + } + + /** + * 主嵌套方法 + * @param {Object} bin - 容器数据 + * @param {Array} parts - 要嵌套的零件数组 + * @param {Number} iterations - 迭代次数 + * @returns {Promise} 返回嵌套结果的Promise + */ + nest(bin, parts, iterations = 10) { + return new Promise((resolve, reject) => { + try { + console.log(`开始嵌套 ${parts.length} 个零件,迭代次数: ${iterations}`); + + if (!bin || !parts || !Array.isArray(parts) || parts.length === 0) { + return reject(new Error('输入数据无效')); + } + + // 预处理容器和零件 + const processedBin = this.preprocessBin(bin); + const processedParts = this.preprocessParts(parts); + + // 初始化遗传算法 + const ga = this.initGA(processedBin, processedParts); + + // 运行遗传算法 + this.runGA(ga, iterations).then(results => { + // 后处理结果 + const finalResults = this.postprocessResults(results, processedBin, processedParts); + resolve(finalResults); + }); + } catch (error) { + console.error('嵌套错误:', error); + reject(error); + } + }); + } + + /** + * 预处理容器 + */ + preprocessBin(bin) { + console.log('预处理容器'); + // 检查容器数据 + if (!bin || !bin.points || !Array.isArray(bin.points) || bin.points.length < 3) { + throw new Error('无效的容器数据'); + } + + // 转换为ClipperLib格式 + const binPath = bin.points.map(point => ({ + X: Math.round(point[0] * this.config.clipperScale), + Y: Math.round(point[1] * this.config.clipperScale) + })); + + // 确保路径是闭合的 + if (binPath.length > 0 && + (binPath[0].X !== binPath[binPath.length-1].X || + binPath[0].Y !== binPath[binPath.length-1].Y)) { + binPath.push({...binPath[0]}); + } + + // 计算面积和边界 + const area = Math.abs(ClipperLib.Clipper.Area(binPath)); + const bounds = this.getPolygonBounds(binPath); + + return { + ...bin, + clipperPath: binPath, + area: area, + bounds: bounds + }; + } + + /** + * 预处理零件 + */ + preprocessParts(parts) { + console.log('预处理零件'); + return parts.map(part => { + // 检查零件数据 + if (!part || !part.points || !Array.isArray(part.points) || part.points.length < 3) { + console.warn(`跳过无效零件: ${part.id || 'unknown'}`); + return null; + } + + // 转换为ClipperLib格式 + const partPath = part.points.map(point => ({ + X: Math.round(point[0] * this.config.clipperScale), + Y: Math.round(point[1] * this.config.clipperScale) + })); + + // 确保路径是闭合的 + if (partPath.length > 0 && + (partPath[0].X !== partPath[partPath.length-1].X || + partPath[0].Y !== partPath[partPath.length-1].Y)) { + partPath.push({...partPath[0]}); + } + + // 计算面积和边界 + const area = Math.abs(ClipperLib.Clipper.Area(partPath)); + const bounds = this.getPolygonBounds(partPath); + + return { + ...part, + clipperPath: partPath, + area: area, + bounds: bounds, + rotations: [] // 用于存储不同旋转角度的几何数据 + }; + }).filter(part => part !== null); // 过滤无效零件 + } + + /** + * 获取多边形边界 + */ + getPolygonBounds(polygon) { + let minX = Number.MAX_VALUE, minY = Number.MAX_VALUE; + let maxX = -Number.MAX_VALUE, maxY = -Number.MAX_VALUE; + + for (const point of polygon) { + minX = Math.min(minX, point.X); + minY = Math.min(minY, point.Y); + maxX = Math.max(maxX, point.X); + maxY = Math.max(maxY, point.Y); + } + + return { + minX, minY, maxX, maxY, + width: (maxX - minX) / this.config.clipperScale, + height: (maxY - minY) / this.config.clipperScale + }; + } + + /** + * 初始化遗传算法 + */ + initGA(bin, parts) { + console.log('初始化遗传算法'); + + // 预计算不同旋转角度的零件 + this.precomputeRotations(parts); + + // 按面积降序排序零件 + const sortedParts = [...parts].sort((a, b) => b.area - a.area); + + // 创建初始种群 + const population = []; + for (let i = 0; i < this.config.populationSize; i++) { + // 为每个个体创建一个随机的放置顺序 + const placementOrder = this.shuffleArray([...Array(sortedParts.length).keys()]); + + population.push({ + fitness: 0, + placementOrder: placementOrder, + placements: [], + unplacedParts: sortedParts.map(p => p.id || ''), + generation: 0 + }); + } + + return { + bin, + parts: sortedParts, + population, + generation: 0, + totalArea: sortedParts.reduce((sum, p) => sum + p.area, 0), + bestSolution: null + }; + } + + /** + * 预计算零件旋转 + */ + precomputeRotations(parts) { + console.log('预计算零件旋转'); + + for (const part of parts) { + for (let i = 0; i < this.config.rotations; i++) { + const angle = (360 / this.config.rotations) * i; + const rotatedPath = this.rotatePoly(part.clipperPath, angle); + + part.rotations.push({ + angle, + clipperPath: rotatedPath, + bounds: this.getPolygonBounds(rotatedPath) + }); + } + } + } + + /** + * 旋转多边形 + */ + rotatePoly(polygon, angleDegrees) { + const angleRadians = angleDegrees * Math.PI / 180; + const sin = Math.sin(angleRadians); + const cos = Math.cos(angleRadians); + + // 找到多边形中心点 + let minX = Number.MAX_VALUE, minY = Number.MAX_VALUE; + let maxX = -Number.MAX_VALUE, maxY = -Number.MAX_VALUE; + + for (const point of polygon) { + minX = Math.min(minX, point.X); + minY = Math.min(minY, point.Y); + maxX = Math.max(maxX, point.X); + maxY = Math.max(maxY, point.Y); + } + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + // 绕中心旋转每个点 + return polygon.map(point => { + const x = point.X - centerX; + const y = point.Y - centerY; + + const rotatedX = x * cos - y * sin; + const rotatedY = x * sin + y * cos; + + return { + X: Math.round(rotatedX + centerX), + Y: Math.round(rotatedY + centerY) + }; + }); + } + + /** + * 异步运行遗传算法 + */ + async runGA(ga, iterations) { + console.log(`运行遗传算法 ${iterations} 次迭代`); + + let bestSolution = { + fitness: 0, + placements: [], + unplacedParts: ga.parts.map(p => p.id || '') + }; + + // 逐代进行遗传算法 + for (let i = 0; i < iterations; i++) { + // 使用事件循环让进度回调有机会运行 + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + + // 报告进度 + if (this.progressCallback) { + this.progressCallback(i / iterations); + } + } + + // 评估当前种群 + await this.evaluatePopulation(ga); + + // 按适应度排序 + ga.population.sort((a, b) => b.fitness - a.fitness); + + // 更新最佳解决方案 + if (ga.population[0].fitness > bestSolution.fitness) { + bestSolution = { + fitness: ga.population[0].fitness, + placements: [...ga.population[0].placements], + unplacedParts: [...ga.population[0].unplacedParts] + }; + console.log(`迭代 ${i}: 发现更好的解决方案,适应度: ${bestSolution.fitness.toFixed(4)}`); + } + + // 创建新一代 + if (i < iterations - 1) { + this.evolvePopulation(ga); + } + + ga.generation++; + } + + // 报告最终进度 + if (this.progressCallback) { + this.progressCallback(1.0); + } + + console.log(`遗传算法完成,最佳适应度: ${bestSolution.fitness.toFixed(4)}`); + return bestSolution; + } + + /** + * 评估种群 + */ + async evaluatePopulation(ga) { + // 并行评估多个个体 + const evaluationPromises = ga.population.map(individual => + this.evaluateIndividual(individual, ga.bin, ga.parts) + ); + + await Promise.all(evaluationPromises); + } + + /** + * 评估单个个体 + */ + async evaluateIndividual(individual, bin, allParts) { + // 模拟放置零件 + const result = await this.placePartsAccordingToIndividual(individual, bin, allParts); + + // 计算适应度 + const placedArea = result.placedArea; + const binArea = bin.area; + const utilization = placedArea / binArea; + const placedPartsRatio = result.placements.length / allParts.length; + + // 适应度函数:结合利用率和已放置零件比例 + individual.fitness = utilization * 0.7 + placedPartsRatio * 0.3; + individual.placements = result.placements; + individual.unplacedParts = result.unplacedParts; + + return individual; + } + + /** + * 根据个体基因放置零件 + */ + async placePartsAccordingToIndividual(individual, bin, allParts) { + const placements = []; + const unplacedParts = []; + let placedArea = 0; + const placedPolygons = []; + + // 依次尝试放置每个零件 + for (const geneIndex of individual.placementOrder) { + if (geneIndex >= allParts.length) continue; + + const part = allParts[geneIndex]; + let bestPlacement = null; + let bestFitness = -1; + + // 尝试不同的旋转角度 + for (let r = 0; r < part.rotations.length; r++) { + const rotation = part.rotations[r]; + + // 寻找最佳放置位置 + const placement = this.findBestPlacement(rotation, bin, placedPolygons); + + if (placement && placement.fitness > bestFitness) { + bestFitness = placement.fitness; + bestPlacement = { + ...placement, + partId: part.id || `part${geneIndex}`, + rotation: rotation.angle + }; + } + } + + if (bestPlacement) { + // 放置成功 + placements.push(bestPlacement); + placedPolygons.push({ + points: this.transformPolygon(part.clipperPath, bestPlacement), + rotation: bestPlacement.rotation + }); + placedArea += part.area; + } else { + // 放置失败 + unplacedParts.push(part.id || `part${geneIndex}`); + } + } + + return { + placements, + unplacedParts, + placedArea + }; + } + + /** + * 变换多边形(平移和旋转) + */ + transformPolygon(polygon, placement) { + // 实现多边形变换 + // ...简化实现,实际代码中需要真正的变换计算 + return polygon; + } + + /** + * 寻找最佳放置位置 + */ + findBestPlacement(rotatedPart, bin, placedParts) { + // 使用基于边界的简单布局策略 + // 在实际实现中,这里应该使用NFP和其他高级算法 + + // 简化实现:尝试在bin中寻找一个可行位置 + const binBounds = bin.bounds; + const partBounds = rotatedPart.bounds; + + // 简单起见,尝试从左上角开始放置 + const x = binBounds.minX / this.config.clipperScale + 10; + const y = binBounds.minY / this.config.clipperScale + 10; + + // 检查是否在bin内并且不与其他零件重叠 + const fits = true; // 此处简化,实际需要检查碰撞 + + if (fits) { + return { + x, + y, + fitness: 1.0, // 简化的适应度计算 + }; + } + + return null; + } + + /** + * 进化种群 + */ + evolvePopulation(ga) { + const nextGeneration = []; + + // 精英策略:保留最优个体 + nextGeneration.push(ga.population[0]); + + // 生成新一代 + while (nextGeneration.length < this.config.populationSize) { + // 选择父代 + const parent1 = this.tournamentSelection(ga.population); + const parent2 = this.tournamentSelection(ga.population); + + // 交叉 + const child = this.crossover(parent1, parent2); + + // 变异 + if (Math.random() < this.config.mutationRate / 100) { + this.mutate(child); + } + + nextGeneration.push(child); + } + + ga.population = nextGeneration; + } + + /** + * 锦标赛选择法 + */ + tournamentSelection(population) { + const tournamentSize = Math.max(2, Math.floor(population.length * 0.2)); + let best = null; + + // 随机选择几个个体,取其中最好的 + for (let i = 0; i < tournamentSize; i++) { + const randomIndex = Math.floor(Math.random() * population.length); + const individual = population[randomIndex]; + + if (best === null || individual.fitness > best.fitness) { + best = individual; + } + } + + return best; + } + + /** + * 交叉操作 + */ + crossover(parent1, parent2) { + // 部分映射交叉 (PMX) + const size = parent1.placementOrder.length; + const child = { + fitness: 0, + placementOrder: new Array(size), + placements: [], + unplacedParts: [] + }; + + // 随机选择交叉点 + const cxPoint1 = Math.floor(Math.random() * size); + const cxPoint2 = Math.floor(Math.random() * (size - cxPoint1)) + cxPoint1; + + // 初始化映射 + const mapping = new Map(); + + // 复制交叉区间 + for (let i = cxPoint1; i <= cxPoint2; i++) { + child.placementOrder[i] = parent1.placementOrder[i]; + mapping.set(parent1.placementOrder[i], parent2.placementOrder[i]); + } + + // 填充其余位置 + for (let i = 0; i < size; i++) { + // 跳过已填充的位置 + if (i >= cxPoint1 && i <= cxPoint2) continue; + + let item = parent2.placementOrder[i]; + + // 检查是否需要映射 + while (mapping.has(item)) { + item = mapping.get(item); + } + + child.placementOrder[i] = item; + } + + return child; + } + + /** + * 变异操作 + */ + mutate(individual) { + // 交换变异:随机选择两个位置并交换 + const size = individual.placementOrder.length; + const pos1 = Math.floor(Math.random() * size); + const pos2 = Math.floor(Math.random() * size); + + const temp = individual.placementOrder[pos1]; + individual.placementOrder[pos1] = individual.placementOrder[pos2]; + individual.placementOrder[pos2] = temp; + } + + /** + * 后处理结果 + */ + postprocessResults(solution, bin, parts) { + console.log('后处理结果'); + + // 计算利用率 + const placedArea = solution.placements.reduce((sum, p) => { + const part = parts.find(part => part.id === p.partId); + return sum + (part ? part.area : 0); + }, 0); + + const binArea = bin.area; + const utilization = placedArea / binArea; + + // 将结果转换为API格式 + const formattedPlacements = solution.placements.map(p => ({ + partId: p.partId, + x: p.x, + y: p.y, + rotation: p.rotation, + transform: `translate(${p.x},${p.y}) rotate(${p.rotation})` + })); + + return { + placements: formattedPlacements, + utilization, + unplacedParts: solution.unplacedParts + }; + } + + /** + * 洗牌算法 + */ + shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + } +} + +// 导出实例 +const svgnestCore = new SVGnestCore(); +export default svgnestCore; diff --git a/src/svgnest-module.js b/src/svgnest-module.js new file mode 100644 index 00000000..b8ad0dd2 --- /dev/null +++ b/src/svgnest-module.js @@ -0,0 +1,51 @@ +/** + * SVGnest module + * 适配器,使用核心模块并提供简化的API接口 + */ +import svgnestCore from './svgnest-core.js'; + +class SVGnestModule { + constructor() { + // Default configuration + this.config = { + clipperScale: 10000000, + curveTolerance: 0.3, + spacing: 0, + rotations: 4, + populationSize: 10, + mutationRate: 10, + useHoles: true, + exploreConcave: false + }; + + this.progressCallback = null; + } + + setConfig(config) { + this.config = { ...this.config, ...config }; + svgnestCore.setConfig(this.config); + return this; + } + + setProgressCallback(callback) { + this.progressCallback = callback; + svgnestCore.setProgressCallback(callback); + return this; + } + + /** + * 主嵌套函数 + * @param {Object} bin - The bin container data + * @param {Array} parts - Array of parts to nest + * @param {Number} iterations - Number of GA iterations to run + * @returns {Promise} - Promise resolving to the nesting results + */ + nest(bin, parts, iterations = 10) { + console.log(`调用嵌套功能,零件数量: ${parts.length}, 迭代次数: ${iterations}`); + return svgnestCore.nest(bin, parts, iterations); + } +} + +// Create singleton instance +const svgNestInstance = new SVGnestModule(); +export default svgNestInstance; diff --git a/src/svgparser-module.js b/src/svgparser-module.js new file mode 100644 index 00000000..f1b73439 --- /dev/null +++ b/src/svgparser-module.js @@ -0,0 +1,311 @@ +import { DOMParser, XMLSerializer } from 'xmldom'; +import GeometryUtil from './geometryutil-module.js'; +import { Matrix } from './matrix-module.js'; + +class SvgParser { + constructor() { + // the SVG document + this.svg = null; + + // the top level SVG element of the SVG document + this.svgRoot = null; + + this.allowedElements = ['svg','circle','ellipse','path','polygon','polyline','rect', 'line']; + + this.conf = { + tolerance: 2, // max bound for bezier->line segment conversion, in native SVG units + toleranceSvg: 0.005 // fudge factor for browser inaccuracy in SVG unit handling + }; + } + + config(config) { + this.conf.tolerance = config.tolerance; + return this; + } + + load(svgString) { + if(!svgString || typeof svgString !== 'string') { + throw Error('invalid SVG string'); + } + + const parser = new DOMParser(); + const svg = parser.parseFromString(svgString, "image/svg+xml"); + + this.svgRoot = false; + + if(svg) { + this.svg = svg; + + for(let i=0; i 0) { + commands[commands.length - 1].values.push(parseFloat(parts[i])); + } + } + + let currentX = 0, currentY = 0; + commands.forEach(cmd => { + switch (cmd.cmd) { + case 'M': + if (cmd.values.length >= 2) { + currentX = cmd.values[0]; + currentY = cmd.values[1]; + poly.push({ x: currentX, y: currentY }); + } + break; + case 'L': + if (cmd.values.length >= 2) { + currentX = cmd.values[0]; + currentY = cmd.values[1]; + poly.push({ x: currentX, y: currentY }); + } + break; + case 'H': + if (cmd.values.length >= 1) { + currentX = cmd.values[0]; + poly.push({ x: currentX, y: currentY }); + } + break; + case 'V': + if (cmd.values.length >= 1) { + currentY = cmd.values[0]; + poly.push({ x: currentX, y: currentY }); + } + break; + } + }); + } + break; + + case 'line': + const x1 = parseFloat(element.getAttribute('x1') || 0); + const y1 = parseFloat(element.getAttribute('y1') || 0); + const x2 = parseFloat(element.getAttribute('x2') || 0); + const y2 = parseFloat(element.getAttribute('y2') || 0); + + poly.push({ x: x1, y: y1 }); + poly.push({ x: x2, y: y2 }); + break; + + default: + console.log(`SVGParser: Unsupported element type: ${element.tagName}`); + } + } catch (error) { + console.error('SVGParser: Error in polygonify:', error); + } + + // Remove duplicates + if (poly.length > 1) { + // Check if first and last points are the same (closed polygon) + const first = poly[0]; + const last = poly[poly.length - 1]; + if (Math.abs(first.x - last.x) < this.conf.toleranceSvg && + Math.abs(first.y - last.y) < this.conf.toleranceSvg) { + poly.pop(); // Remove last point as it's a duplicate + } + } + + console.log(`SVGParser: Returning polygon with ${poly.length} points`); + return poly; + } +} + +export default SvgParser;