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 = `
+`;
+
+ 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;