├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .travis.yml ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── __snapshots__ │ └── schematic.spec.ts.snap ├── board.spec.ts ├── board.ts ├── easyeda-types.ts ├── fixtures │ └── testingEasyEDA.json ├── index.ts ├── main.ts ├── schematic.spec.ts ├── schematic.ts ├── spectra.spec.ts ├── spectra.ts └── svg-arc.ts ├── tsconfig.json ├── tsconfig.spec.json ├── tslint.commit.json ├── tslint.json └── wallaby.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node: [ '14', '12', '10' ] 11 | name: Node ${{ matrix.node }} CI 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Setup node 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node }} 18 | - name: npm install, build, lint, and test 19 | run: | 20 | npm ci 21 | npm run build 22 | npm run lint 23 | npm test 24 | env: 25 | CI: true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | .idea 3 | node_modules 4 | dist 5 | *.log 6 | *.kicad_pcb 7 | *.kicad_pcb-bak 8 | *.pro 9 | fp-info-cache 10 | *.sch 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 10 5 | - 12 6 | - 14 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | 4 | "recommendations": [ 5 | "editorconfig.editorconfig", 6 | "esbenp.prettier-vscode", 7 | "ms-vscode.vscode-typescript-tslint-plugin" 8 | ], 9 | 10 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 11 | "unwantedRecommendations": [] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Uri Shaked 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | --- 24 | 25 | ## License for src/svg-arc.ts 26 | 27 | Copyright 2001-2003 The Apache Software Foundation 28 | 29 | Licensed under the Apache License, Version 2.0 (the "License"); 30 | you may not use this file except in compliance with the License. 31 | You may obtain a copy of the License at 32 | 33 | http://www.apache.org/licenses/LICENSE-2.0 34 | 35 | Unless required by applicable law or agreed to in writing, software 36 | distributed under the License is distributed on an "AS IS" BASIS, 37 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 38 | See the License for the specific language governing permissions and 39 | limitations under the License. 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project is no longer maintained! 2 | 3 | We recommend [the easyeda2kicad python package](https://github.com/uPesy/easyeda2kicad.py) instead. 4 | 5 | # EasyEDA 2 KiCad 6 | 7 | Convert EasyEDA PCBs to KiCad format 8 | 9 | [![Build Status](https://travis-ci.org/wokwi/easyeda2kicad.png?branch=master)](https://travis-ci.org/wokwi/easyeda2kicad) 10 | 11 | ## Online Converter 12 | 13 | The easiest way to convert EasyEDA boards to KiCad format is to use the online convertor: 14 | 15 | https://wokwi.com/easyeda2kicad 16 | 17 | ## Installation 18 | 19 | ``` 20 | npm install -g easyeda2kicad 21 | ``` 22 | 23 | ## Usage 24 | 25 | ``` 26 | easyeda2kicad [output.kicad_pcb] 27 | ``` 28 | 29 | ## Notes 30 | 31 | Copper zones are converted but not filled. When you load the converted PCB in KiCad press "b" (or "Edit" → "Fill All Zones") to recalculate the zones. 32 | 33 | ## License 34 | 35 | Copyright (C) 2019, Uri Shaked. 36 | 37 | Most of the code is released under the MIT license, with the exception of [src/svg-arc.ts](src/svg-arc.ts), which is 38 | released under the Apache 2.0 license. 39 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: 'tsconfig.spec.json', 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easyeda2kicad", 3 | "version": "1.9.5", 4 | "bin": "dist/main.js", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "author": "Uri Shaked ", 8 | "repository": "https://github.com/wokwi/easyeda2kicad", 9 | "license": "MIT", 10 | "scripts": { 11 | "build": "tsc", 12 | "prepare": "npm run build", 13 | "start": "ts-node src/main.ts", 14 | "lint": "tslint --project tsconfig.json", 15 | "test": "jest" 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "devDependencies": { 21 | "@types/jest": "^27.0.2", 22 | "@types/node": "^12.12.7", 23 | "husky": "^3.0.9", 24 | "jest": "^27.3.1", 25 | "lint-staged": "^13.2.1", 26 | "prettier": "^2.0.4", 27 | "ts-jest": "^27.0.7", 28 | "ts-node": "^8.5.0", 29 | "tslint": "^5.20.1", 30 | "typescript": "^3.9.7" 31 | }, 32 | "husky": { 33 | "hooks": { 34 | "pre-commit": "lint-staged" 35 | } 36 | }, 37 | "lint-staged": { 38 | "src/**/*.{ts,tsx}": [ 39 | "tslint --project tsconfig.spec.json -c tslint.commit.json --fix", 40 | "prettier --write", 41 | "git add" 42 | ] 43 | }, 44 | "engines": { 45 | "node": ">= 8.0.0", 46 | "npm": ">= 5.0.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | printWidth: 100, 4 | singleQuote: true, 5 | tabWidth: 2 6 | }; 7 | -------------------------------------------------------------------------------- /src/__snapshots__/schematic.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`convertSchematic should convert an EasyEDA schematic file 1`] = ` 4 | "EESchema Schematic File Version 4 5 | EELAYER 30 0 6 | EELAYER END 7 | Wire Wire Line 8 | 5555 2805 5390 2805 9 | Wire Wire Line 10 | 5390 2805 5390 3135 11 | Wire Wire Line 12 | 5390 3135 5555 3135 13 | Wire Wire Line 14 | 5500 2970 5390 2970 15 | Wire Wire Line 16 | 5775 3135 5720 3135 17 | Wire Wire Line 18 | 5720 3135 5610 3135 19 | Wire Wire Line 20 | 5610 3135 5610 2970 21 | Wire Wire Line 22 | 5610 2970 5775 2970 23 | Wire Wire Line 24 | 5775 2970 5775 2805 25 | Wire Wire Line 26 | 5775 2805 5610 2805 27 | Wire Wire Line 28 | 5830 2805 5830 2970 29 | Wire Wire Line 30 | 5830 2970 5885 2970 31 | Wire Wire Line 32 | 5885 2970 5995 2805 33 | Wire Wire Line 34 | 5830 3135 5830 2970 35 | Wire Wire Line 36 | 5995 3135 5940 2970 37 | Wire Wire Line 38 | 5940 2970 5830 2970 39 | Connection ~ 5830 2970 40 | Connection ~ 5390 2970 41 | $EndSCHEMATC" 42 | `; 43 | -------------------------------------------------------------------------------- /src/board.spec.ts: -------------------------------------------------------------------------------- 1 | import { convertBoardToArray, convertShape } from './board'; 2 | import { encodeObject, ISpectraList } from './spectra'; 3 | 4 | function removeNulls(a: ISpectraList): ISpectraList { 5 | return a 6 | .map((item) => (item instanceof Array ? removeNulls(item) : item)) 7 | .filter((e) => e != null); 8 | } 9 | 10 | function round(obj: ISpectraList | string | number, precision = 3): ISpectraList | string | number { 11 | if (obj instanceof Array) { 12 | return obj.map((item) => round(item, precision)); 13 | } 14 | if (typeof obj === 'number') { 15 | const result = parseFloat(obj.toFixed(precision)); 16 | if (result > -Number.EPSILON && result < Number.EPSILON) { 17 | return 0; 18 | } 19 | return result; 20 | } 21 | return obj; 22 | } 23 | 24 | function normalize(obj: ISpectraList) { 25 | return round(removeNulls(obj)); 26 | } 27 | 28 | function conversionState(nets: string[] = []) { 29 | return { nets, innerLayers: 0 }; 30 | } 31 | 32 | describe('convertTrack', () => { 33 | it('should convert copper tracks into segments', () => { 34 | const input = 'TRACK~0.63~1~GND~4000 3000 4000 3030~gge606~0'; 35 | expect(normalize(convertShape(input, conversionState(['', 'GND'])))).toEqual([ 36 | [ 37 | 'segment', 38 | ['start', 0, 0], 39 | ['end', 0, 7.62], 40 | ['width', 0.16], 41 | ['layer', 'F.Cu'], 42 | ['net', 1], 43 | ], 44 | ]); 45 | }); 46 | 47 | it(`should throw an error if the given layer number doesn't exist`, () => { 48 | const input = 'TRACK~0.63~999~GND~4000 3000 4000 3030~gge606~0'; 49 | const fn = () => convertShape(input, conversionState(['', 'GND'])); 50 | expect(fn).toThrow('Missing layer id: 999'); 51 | }); 52 | 53 | it('should ignore special EasyEDA layers (issues #44, #51) ', () => { 54 | const input = 'TRACK~0.63~99~GND~4000 3000 4000 3030~gge606~0'; 55 | expect(normalize(convertShape(input, conversionState()))).toEqual([]); 56 | }); 57 | 58 | it('should add missing nets into the netlist (issue #29)', () => { 59 | const input = 'TRACK~0.63~1~5V~4000 3000 4000 3030~gge606~0'; 60 | const nets = ['', 'GND']; 61 | expect(normalize(convertShape(input, conversionState(nets)))).toEqual([ 62 | [ 63 | 'segment', 64 | ['start', 0, 0], 65 | ['end', 0, 7.62], 66 | ['width', 0.16], 67 | ['layer', 'F.Cu'], 68 | ['net', 2], 69 | ], 70 | ]); 71 | expect(nets).toEqual(['', 'GND', '5V']); 72 | }); 73 | 74 | it('should support inner layers (issue #33)', () => { 75 | const input = 'TRACK~0.63~21~GND~4000 3000 4000 3030~gge606~0'; 76 | expect(normalize(convertShape(input, conversionState(['', 'GND'])))).toEqual([ 77 | [ 78 | 'segment', 79 | ['start', 0, 0], 80 | ['end', 0, 7.62], 81 | ['width', 0.16], 82 | ['layer', 'In1.Cu'], 83 | ['net', 1], 84 | ], 85 | ]); 86 | }); 87 | }); 88 | 89 | describe('convertPadToVia', () => { 90 | it('should correctly parse a PAD and convert it to a Via', () => { 91 | const input = 'PAD~ELLIPSE~4150~3071.5~6~6~11~GND~1~1.8~~0~gge196~0~~Y~0~~~4150,3071.5'; 92 | expect(normalize(convertShape(input, conversionState())[0])).toEqual([ 93 | 'via', 94 | ['at', 38.1, 18.161], 95 | ['size', 1.524], 96 | ['drill', 0.914], 97 | ['layers', 'F.Cu', 'B.Cu'], 98 | ['net', 0], 99 | ]); 100 | }); 101 | 102 | it('should wrap non ellipse pads in KiCad Footprint', () => { 103 | const input = 'PAD~RECT~4150~3071.5~6~6~11~VCC~1~1.8~~0~gge196~0~~Y~0~~~4150,3071.5'; 104 | expect(normalize(convertShape(input, conversionState(['', 'VCC']))[0])).toEqual([ 105 | 'module', 106 | 'AutoGenerated:Pad_1.52mm', 107 | ['layer', 'F.Cu'], 108 | ['at', 38.1, 18.161], 109 | ['attr', 'virtual'], 110 | ['fp_text', 'reference', '', ['at', 0, 0], ['layer', 'F.SilkS']], 111 | ['fp_text', 'value', '', ['at', 0, 0], ['layer', 'F.SilkS']], 112 | [ 113 | 'pad', 114 | 1, 115 | 'thru_hole', 116 | 'rect', 117 | ['at', 0, 0, 0], 118 | ['size', 1.524, 1.524], 119 | ['layers', '*.Cu', '*.Paste', '*.Mask'], 120 | ['drill', 0.914], 121 | ['net', 1, 'VCC'], 122 | ], 123 | ]); 124 | }); 125 | }); 126 | 127 | describe('convertArc', () => { 128 | it('should convert arcs', () => { 129 | const input = 'ARC~1~10~~M4050,3060 A10,10 0 0 1 4060,3050~~gge276~0'; 130 | expect(encodeObject(convertShape(input, conversionState())[0])).toEqual( 131 | '(gr_arc (start 15.24 15.24) (end 12.7 15.24) (angle 90) (width 0.254) (layer "Edge.Cuts"))' 132 | ); 133 | }); 134 | 135 | it('should parse different path formats', () => { 136 | const input = 'ARC~1~10~~M4000 3000A10 10 0 0 1 4050 3050~~gge170~0'; 137 | expect(encodeObject(convertShape(input, conversionState())[0])).toEqual( 138 | '(gr_arc (start 6.35 6.35) (end 0 0) (angle 180) (width 0.254) (layer "Edge.Cuts"))' 139 | ); 140 | }); 141 | 142 | it('should support negative numbers in arc path', () => { 143 | const input = 144 | 'ARC~0.6~4~~M 3977.3789 3026.2151 A 28.4253 28.4253 -150 1 1 3977.6376 3026.643~~gge66~0'; 145 | expect(encodeObject(convertShape(input, conversionState())[0])).toEqual( 146 | '(gr_arc (start 0.465 2.978) (end -5.746 6.659) (angle 358.992) (width 0.152) (layer "B.SilkS"))' 147 | ); 148 | }); 149 | 150 | it('should correctly determine the arc start and end point (issue #16)', () => { 151 | const input = 'ARC~1~1~S$9~M4262.5,3279.5 A33.5596,33.5596 0 0 0 4245.5921,3315.5816~~gge8~0'; 152 | expect(encodeObject(convertShape(input, conversionState())[0])).toEqual( 153 | '(gr_arc (start 70.739 78.486) (end 62.38 80.158) (angle 72.836) (width 0.254) (layer "F.Cu"))' 154 | ); 155 | }); 156 | }); 157 | 158 | describe('convertCopperArea', () => { 159 | it('should correctly parse the given SVG path', () => { 160 | const input = 161 | 'COPPERAREA~1~2~GND~M 4050 3050 L 4164 3050 L 4160 3120 L4050,3100 Z~1~solid~gge221~spoke~none~~0~~2~1~1~0~yes'; 162 | expect(normalize(convertShape(input, conversionState(['', 'GND']))[0])).toEqual([ 163 | 'zone', 164 | ['net', 1], 165 | ['net_name', 'GND'], 166 | ['layer', 'B.Cu'], 167 | ['hatch', 'edge', 0.508], 168 | ['connect_pads', ['clearance', 0.254]], 169 | [ 170 | 'polygon', 171 | ['pts', ['xy', 12.7, 12.7], ['xy', 41.656, 12.7], ['xy', 40.64, 30.48], ['xy', 12.7, 25.4]], 172 | ], 173 | ]); 174 | }); 175 | }); 176 | 177 | describe('convertSolidRegion', () => { 178 | it('should correctly parse the given SVG path', () => { 179 | const input = 180 | 'SOLIDREGION~2~L3_2~M 4280 3173 L 4280 3127.5 L 4358.5 3128 L 4358.5 3163.625 L 4371.5 3163.625 L 4374.5 3168.625 L 4374.5 3173.125 L 4369 3173.125 L 4358.5 3173.125 L 4358.5 3179.625 L 4406.5 3179.625 L 4459 3179.5 L 4459 3252.5 L 4280.5 3253 L 4280 3173 Z~cutout~gge40~0'; 181 | expect(normalize(convertShape(input, conversionState(['L3_2']))[0])).toEqual([ 182 | 'zone', 183 | ['net', 0], 184 | ['net_name', ''], 185 | ['hatch', 'edge', 0.508], 186 | ['layer', 'B.Cu'], 187 | ['keepout', ['tracks', 'allowed'], ['vias', 'allowed'], ['copperpour', 'not_allowed']], 188 | [ 189 | 'polygon', 190 | [ 191 | 'pts', 192 | ['xy', 71.12, 43.942], 193 | ['xy', 71.12, 32.385], 194 | ['xy', 91.059, 32.512], 195 | ['xy', 91.059, 41.561], 196 | ['xy', 94.361, 41.561], 197 | ['xy', 95.123, 42.831], 198 | ['xy', 95.123, 43.974], 199 | ['xy', 93.726, 43.974], 200 | ['xy', 91.059, 43.974], 201 | ['xy', 91.059, 45.625], 202 | ['xy', 103.251, 45.625], 203 | ['xy', 116.586, 45.593], 204 | ['xy', 116.586, 64.135], 205 | ['xy', 71.247, 64.262], 206 | ['xy', 71.12, 43.942], 207 | ], 208 | ], 209 | ]); 210 | }); 211 | 212 | it('should ignore solid regions with circles (issue #12)', () => { 213 | const input = 214 | 'SOLIDREGION~1~~M 4367 3248 A 33.8 33.8 0 1 0 4366.99 3248 Z ~cutout~gge1953~~~~0'; 215 | expect(normalize(convertShape(input, conversionState()))).toEqual([]); 216 | }); 217 | 218 | it('should ignore solid regions on layer 100 (issue #50)', () => { 219 | const input = 220 | 'SOLIDREGION~100~~M 4367 3248 A 33.8 33.8 0 1 0 4366.99 3248 Z ~cutout~gge1953~~~~0'; 221 | expect(normalize(convertShape(input, conversionState()))).toEqual([]); 222 | }); 223 | }); 224 | 225 | describe('convertHole()', () => { 226 | it('should convert HOLE into KiCad footprint', () => { 227 | const input = 'HOLE~4475.5~3170.5~2.9528~gge1205~1'; 228 | expect(normalize(convertShape(input, conversionState())[0])).toEqual([ 229 | 'module', 230 | 'AutoGenerated:MountingHole_1.50mm', 231 | 'locked', 232 | ['layer', 'F.Cu'], 233 | ['at', 120.777, 43.307], 234 | ['attr', 'virtual'], 235 | ['fp_text', 'reference', '', ['at', 0, 0], ['layer', 'F.SilkS']], 236 | ['fp_text', 'value', '', ['at', 0, 0], ['layer', 'F.SilkS']], 237 | [ 238 | 'pad', 239 | '', 240 | 'np_thru_hole', 241 | 'circle', 242 | ['at', 0, 0], 243 | ['size', 1.5, 1.5], 244 | ['drill', 1.5], 245 | ['layers', '*.Cu', '*.Mask'], 246 | ], 247 | ]); 248 | }); 249 | }); 250 | 251 | describe('convert circle', () => { 252 | it('should correctly determine the end point according to radius', () => { 253 | const input = 'CIRCLE~4000~3000~12.4~1~3~gge635~0~'; 254 | expect(normalize(convertShape(input, conversionState())[0])).toEqual([ 255 | 'gr_circle', 256 | ['center', 0, 0], 257 | ['end', 3.15, 0], 258 | ['layer', 'F.SilkS'], 259 | ['width', 0.254], 260 | ]); 261 | }); 262 | }); 263 | 264 | describe('convertLib()', () => { 265 | it('should include the footprint name in the exported module', () => { 266 | const input = 267 | 'LIB~4228~3187.5~package`1206`~270~~gge12~2~a8f323e85d754372811837f27f204a01~1564555550~0'; 268 | expect(normalize(convertShape(input, conversionState())[0])).toEqual([ 269 | 'module', 270 | 'easyeda:1206', 271 | ['layer', 'F.Cu'], 272 | ['at', 57.912, 47.625, -90], 273 | [ 274 | 'fp_text', 275 | 'user', 276 | 'gge12', 277 | ['at', 0, 0], 278 | ['layer', 'Cmts.User'], 279 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 280 | ], 281 | ]); 282 | }); 283 | 284 | it('should correctly orient footprint elements', () => { 285 | const lib = 286 | 'LIB~4228~3187.5~package`1206`~270~~gge12~2~a8f323e85d754372811837f27f204a01~1564555550~0'; 287 | const pad = 288 | '#@$PAD~ELLIPSE~4010~3029~4~4~11~SEG1C~4~1.5~~270~gge181~0~~Y~0~0~0.4~4010.05,3029.95'; 289 | const input = lib + pad; 290 | expect(normalize(convertShape(input, conversionState())[0])).toEqual([ 291 | 'module', 292 | 'easyeda:1206', 293 | ['layer', 'F.Cu'], 294 | ['at', 57.912, 47.625, -90], 295 | [ 296 | 'pad', 297 | 4, 298 | 'thru_hole', 299 | 'circle', 300 | ['at', -40.259, 55.372, -90], 301 | ['size', 1.016, 1.016], 302 | ['layers', '*.Cu', '*.Paste', '*.Mask'], 303 | ['drill', 0.762], 304 | ], 305 | [ 306 | 'fp_text', 307 | 'user', 308 | 'gge12', 309 | ['at', 0, 0], 310 | ['layer', 'Cmts.User'], 311 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 312 | ], 313 | ]); 314 | }); 315 | 316 | it('should correctly orient text inside footprints', () => { 317 | const lib = 318 | 'LIB~4228~3187.5~package`1206`~270~~gge12~2~a8f323e85d754372811837f27f204a01~1564555550~0'; 319 | const text = 320 | '#@$TEXT~N~4363~3153~0.6~90~~3~~4.5~0.5pF~M 4359.51 3158.63 L 4359.71 3159.25~none~gge188~~0~'; 321 | const input = lib + text; 322 | expect(normalize(convertShape(input, conversionState())[0])).toEqual([ 323 | 'module', 324 | 'easyeda:1206', 325 | ['layer', 'F.Cu'], 326 | ['at', 57.912, 47.625, -90], 327 | [ 328 | 'fp_text', 329 | 'value', 330 | '0.5pF', 331 | ['at', -8.763, -34.29, 90], 332 | ['layer', 'F.Fab'], 333 | 'hide', 334 | ['effects', ['font', ['size', 1.143, 1.143], ['thickness', 0.152]], ['justify', 'left']], 335 | ], 336 | [ 337 | 'fp_text', 338 | 'user', 339 | 'gge12', 340 | ['at', 0, 0], 341 | ['layer', 'Cmts.User'], 342 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 343 | ], 344 | ]); 345 | }); 346 | 347 | it('should correctly convert pad offsets (issue #11)', () => { 348 | const input = 349 | 'LIB~4177~3107~package`0402`3DModel`R_0603_1608Metric`~90~~gge464~1~405bb71866794ab59459d3b2854a4d33~1541687137~0~#@$TEXT~P~4173.31~3108.77~0.5~90~0~3~~2.4~R2~M 4172.0001 3108.77 L 4174.2901 3108.77 M 4172.0001 3108.77 L 4172.0001 3107.79 L 4172.1101 3107.46 L 4172.2201 3107.35 L 4172.4401 3107.24 L 4172.6601 3107.24 L 4172.8701 3107.35 L 4172.9801 3107.46 L 4173.0901 3107.79 L 4173.0901 3108.77 M 4173.0901 3108.01 L 4174.2901 3107.24 M 4172.5501 3106.41 L 4172.4401 3106.41 L 4172.2201 3106.3 L 4172.1101 3106.2 L 4172.0001 3105.98 L 4172.0001 3105.54 L 4172.1101 3105.32 L 4172.2201 3105.21 L 4172.4401 3105.1 L 4172.6601 3105.1 L 4172.8701 3105.21 L 4173.2001 3105.43 L 4174.2901 3106.52 L 4174.2901 3105~~gge467~~0~#@$TEXT~N~4160~3102.72~0.5~90~0~3~~4.5~2K2~M 4158.57 3102.52 L 4158.36 3102.52 L 4157.95 3102.31 L 4157.75 3102.11 L 4157.55 3101.7 L 4157.55 3100.88 L 4157.75 3100.47 L 4157.95 3100.27 L 4158.36 3100.06 L 4158.77 3100.06 L 4159.18 3100.27 L 4159.8 3100.67 L 4161.84 3102.72 L 4161.84 3099.86 M 4157.55 3098.51 L 4161.84 3098.51 M 4157.55 3095.64 L 4160.41 3098.51 M 4159.39 3097.48 L 4161.84 3095.64 M 4158.57 3094.09 L 4158.36 3094.09 L 4157.95 3093.88 L 4157.75 3093.68 L 4157.55 3093.27 L 4157.55 3092.45 L 4157.75 3092.04 L 4157.95 3091.84 L 4158.36 3091.63 L 4158.77 3091.63 L 4159.18 3091.84 L 4159.8 3092.25 L 4161.84 3094.29 L 4161.84 3091.43~none~gge468~~0~#@$PAD~RECT~4177~3108.67~2.362~2.559~1~SWCLK~1~0~4175.72 3109.85 4175.72 3107.49 4178.28 3107.49 4178.28 3109.85~90~gge466~0~~Y~0~0~0.4~4177,3108.67#@$SVGNODE~{"gId":"gge464_outline","nodeName":"g","nodeType":1,"layerid":"19","attrs":{"c_width":"6.4","c_height":"3.1898","c_rotation":"0,0,90","z":"0","c_origin":"4177,3107.03","uuid":"14d29194d76d4abda3f419dd15e5ae1e","c_etype":"outline3D","id":"gge464_outline","title":"R_0603_1608Metric","layerid":"19","transform":"scale(10.1587) translate(-3765.8265, -2801.1817)","style":""},"childNodes":[{"gId":"gge464_outline_line0","nodeName":"polyline","nodeType":1,"attrs":{"fill":"none","id":"gge464_outline_line0","c_shapetype":"line","points":"4176.843 3107.345 4177.157 3107.345 4177.157 3107.343 4177.157 3107.341 4177.157 3107.338 4177.157 3107.335 4177.157 3107.331 4177.157 3107.327 4177.157 3107.245 4177.157 3107.241 4177.157 3107.237 4177.157 3107.234 4177.157 3107.231 4177.157 3107.229 4177.157 3107.227 4177.157 3106.833 4177.157 3106.831 4177.157 3106.829 4177.157 3106.826 4177.157 3106.823 4177.157 3106.819 4177.157 3106.815 4177.157 3106.733 4177.157 3106.729 4177.157 3106.725 4177.157 3106.722 4177.157 3106.719 4177.157 3106.717 4177.157 3106.715 4176.843 3106.715 4176.843 3106.717 4176.843 3106.719 4176.843 3106.722 4176.843 3106.725 4176.843 3106.729 4176.843 3106.733 4176.843 3106.815 4176.843 3106.819 4176.843 3106.823 4176.843 3106.826 4176.843 3106.829 4176.843 3106.831 4176.843 3106.833 4176.843 3107.227 4176.843 3107.229 4176.843 3107.231 4176.843 3107.234 4176.843 3107.237 4176.843 3107.241 4176.843 3107.245 4176.843 3107.327 4176.843 3107.331 4176.843 3107.335 4176.843 3107.338 4176.843 3107.341 4176.843 3107.343 4176.843 3107.345 4176.843 3107.345'; 350 | 351 | expect(normalize(convertShape(input, conversionState(['', '+3V3', 'SWCLK']))[0])).toEqual([ 352 | 'module', 353 | 'easyeda:0402', 354 | ['layer', 'F.Cu'], 355 | ['at', 44.958, 27.178, 90], 356 | ['attr', 'smd'], 357 | [ 358 | 'fp_text', 359 | 'reference', 360 | 'R2', 361 | ['at', -0.45, -0.937, 90], 362 | ['layer', 'F.SilkS'], 363 | ['effects', ['font', ['size', 0.61, 0.61], ['thickness', 0.127]], ['justify', 'left']], 364 | ], 365 | [ 366 | 'fp_text', 367 | 'value', 368 | '2K2', 369 | ['at', 1.087, -4.318, 90], 370 | ['layer', 'F.Fab'], 371 | 'hide', 372 | ['effects', ['font', ['size', 1.143, 1.143], ['thickness', 0.127]], ['justify', 'left']], 373 | ], 374 | [ 375 | 'pad', 376 | 1, 377 | 'smd', 378 | 'rect', 379 | ['at', -0.424, 0, 90], 380 | ['size', 0.6, 0.65], 381 | ['layers', 'F.Cu', 'F.Paste', 'F.Mask'], 382 | ['net', 2, 'SWCLK'], 383 | ], 384 | [ 385 | 'fp_text', 386 | 'user', 387 | 'gge464', 388 | ['at', 0, 0], 389 | ['layer', 'Cmts.User'], 390 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 391 | ], 392 | ]); 393 | }); 394 | 395 | it('should convert polygons inside footprints (issue #15)', () => { 396 | const input = 397 | 'LIB~4401~3164~package`IEC_HIGHVOLTAGE_SMALL`~~~gge846~1~~~0~#@$SOLIDREGION~3~~M 4400.3 3160.5 L 4401.8 3160.5 L 4399.1 3165.8 L 4402.9 3164.7 L 4400.9 3169.3 L 4401.7 3169.1 L 4400.1 3170.9 L 4399.8 3168.8 L 4400.3 3169.2 L 4401.3 3165.9 L 4397.6 3167.1 Z ~solid~gge849~~~~0'; 398 | 399 | expect(normalize(convertShape(input, conversionState())[0])).toEqual([ 400 | 'module', 401 | 'easyeda:IEC_HIGHVOLTAGE_SMALL', 402 | ['layer', 'F.Cu'], 403 | ['at', 101.854, 41.656], 404 | [ 405 | 'fp_poly', 406 | [ 407 | 'pts', 408 | ['xy', -0.178, -0.889], 409 | ['xy', 0.203, -0.889], 410 | ['xy', -0.483, 0.457], 411 | ['xy', 0.483, 0.178], 412 | ['xy', -0.025, 1.346], 413 | ['xy', 0.178, 1.295], 414 | ['xy', -0.229, 1.753], 415 | ['xy', -0.305, 1.219], 416 | ['xy', -0.178, 1.321], 417 | ['xy', 0.076, 0.483], 418 | ['xy', -0.864, 0.787], 419 | ], 420 | ['layer', 'F.SilkS'], 421 | ['width', 0], 422 | ], 423 | [ 424 | 'fp_text', 425 | 'user', 426 | 'gge846', 427 | ['at', 0, 0], 428 | ['layer', 'Cmts.User'], 429 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 430 | ], 431 | ]); 432 | }); 433 | 434 | it('should not crash if SOLIDREGION contains an arc (issue #15)', () => { 435 | const input = 436 | 'LIB~4401~3164~package`IEC_HIGHVOLTAGE_SMALL`~~~gge846~1~~~0~#@$#@$SOLIDREGION~3~~M 4513.5 3294 A 12.125 12.125 0 0 1 4495.5 3294 Z ~solid~gge636~~~~0'; 437 | 438 | expect(normalize(convertShape(input, conversionState())[0])).toEqual([ 439 | 'module', 440 | 'easyeda:IEC_HIGHVOLTAGE_SMALL', 441 | ['layer', 'F.Cu'], 442 | ['at', 101.854, 41.656], 443 | [ 444 | 'fp_text', 445 | 'user', 446 | 'gge846', 447 | ['at', 0, 0], 448 | ['layer', 'Cmts.User'], 449 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 450 | ], 451 | ]); 452 | }); 453 | 454 | it('should not respected the locked attribute (issue #23)', () => { 455 | const input = 'LIB~4050~3050~package`Test`~~~gge123~1~~~1~'; 456 | 457 | expect(normalize(convertShape(input, conversionState())[0])).toEqual([ 458 | 'module', 459 | 'easyeda:Test', 460 | 'locked', 461 | ['layer', 'F.Cu'], 462 | ['at', 12.7, 12.7], 463 | [ 464 | 'fp_text', 465 | 'user', 466 | 'gge123', 467 | ['at', 0, 0], 468 | ['layer', 'Cmts.User'], 469 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 470 | ], 471 | ]); 472 | }); 473 | 474 | it('should correctly convert non-rectangular polygon-shaped pads (issue #28)', () => { 475 | const input = 476 | 'LIB~612.25~388.7~package`0603`value`1.00k~~~rep30~1~c25f29e5d54148509f1fe8ecc29bd248~1549637911~0~#@$PAD~POLYGON~613.999~396.939~3.9399~3.14~1~GND~1~0~612.03 398.51 612.03 395.37 615.97 398.51~90~rep28~0~~Y~0~0~0.4~613.999,396.939'; 477 | const nets = ['', 'GND', 'B-IN']; 478 | expect(normalize(convertShape(input, conversionState(nets))[0])).toEqual([ 479 | 'module', 480 | 'easyeda:0603', 481 | ['layer', 'F.Cu'], 482 | ['at', -860.488, -663.27], 483 | ['attr', 'smd'], 484 | [ 485 | 'pad', 486 | 1, 487 | 'smd', 488 | 'custom', 489 | ['at', 0.444, 2.093, 90], 490 | ['size', 1.001, 0.798], 491 | ['layers', 'F.Cu', 'F.Paste', 'F.Mask'], 492 | ['net', 1, 'GND'], 493 | [ 494 | 'primitives', 495 | [ 496 | 'gr_poly', 497 | ['pts', ['xy', -0.399, -0.5], ['xy', 0.399, -0.5], ['xy', -0.399, 0.501]], 498 | ['width', 0.1], 499 | ], 500 | ], 501 | ], 502 | [ 503 | 'fp_text', 504 | 'user', 505 | 'rep30', 506 | ['at', 0, 0], 507 | ['layer', 'Cmts.User'], 508 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 509 | ], 510 | ]); 511 | }); 512 | 513 | it('should enforce minimal width and height for polygon pads', () => { 514 | const input = 515 | 'LIB~585.7~338.9~package`0603`value`1.00k~90~~gge35720~1~c25f29e5d54148509f1fe8ecc29bd248~1549637911~0~#@$PAD~POLYGON~593.939~338.901~0~0~1~SYNC-OUT~1~0~595.51 340.87 592.37 340.87 595.51 336.93~180~gge35721~0~~Y~0~0~0.4~593.939,338.901'; 516 | const nets = ['', 'GND', 'B-IN']; 517 | expect(normalize(convertShape(input, conversionState(nets))[0])).toEqual([ 518 | 'module', 519 | 'easyeda:0603', 520 | ['layer', 'F.Cu'], 521 | ['at', -867.232, -675.919, 90], 522 | ['attr', 'smd'], 523 | [ 524 | 'pad', 525 | 1, 526 | 'smd', 527 | 'custom', 528 | ['at', 0, 2.093, 180], 529 | ['size', 0.01, 0.01], 530 | ['layers', 'F.Cu', 'F.Paste', 'F.Mask'], 531 | ['net', 3, 'SYNC-OUT'], 532 | [ 533 | 'primitives', 534 | [ 535 | 'gr_poly', 536 | ['pts', ['xy', -0.399, -0.5], ['xy', 0.399, -0.5], ['xy', -0.399, 0.501]], 537 | ['width', 0.1], 538 | ], 539 | ], 540 | ], 541 | [ 542 | 'fp_text', 543 | 'user', 544 | 'gge35720', 545 | ['at', 0, 0], 546 | ['layer', 'Cmts.User'], 547 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 548 | ], 549 | ]); 550 | }); 551 | 552 | it('should automatically detect rectangular pads that are defined as polygons (issue #28)', () => { 553 | const input = 554 | 'LIB~585.7~338.9~package`0603`value`1.00k~90~~gge35720~1~c25f29e5d54148509f1fe8ecc29bd248~1549637911~0~#@$PAD~POLYGON~593.939~338.901~0~0~1~SYNC-OUT~1~0~595.51 340.87 592.37 340.87 592.37 336.93 595.51 336.93~180~gge35721~0~~Y~0~0~0.4~593.939,338.901#@$PAD~POLYGON~586.459~338.901~3.15~3.94~1~SYNC-OUT~2~0~588.03 340.87 584.88 340.87 584.88 336.93 588.03 336.93~180~gge35727~0~~Y~0~0~0.4~586.459,338.901'; 555 | const nets = ['', 'GND', 'B-IN']; 556 | expect(normalize(convertShape(input, conversionState(nets))[0])).toEqual([ 557 | 'module', 558 | 'easyeda:0603', 559 | ['layer', 'F.Cu'], 560 | ['at', -867.232, -675.919, 90], 561 | ['attr', 'smd'], 562 | [ 563 | 'pad', 564 | 1, 565 | 'smd', 566 | 'rect', 567 | ['at', 0, 2.093, 180], 568 | ['size', 0.798, 1.001], 569 | ['layers', 'F.Cu', 'F.Paste', 'F.Mask'], 570 | ['net', 3, 'SYNC-OUT'], 571 | ], 572 | [ 573 | 'pad', 574 | 2, 575 | 'smd', 576 | 'rect', 577 | ['at', 0, 0.193, 180], 578 | ['size', 0.8, 1.001], 579 | ['layers', 'F.Cu', 'F.Paste', 'F.Mask'], 580 | ['net', 3, 'SYNC-OUT'], 581 | ], 582 | [ 583 | 'fp_text', 584 | 'user', 585 | 'gge35720', 586 | ['at', 0, 0], 587 | ['layer', 'Cmts.User'], 588 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 589 | ], 590 | ]); 591 | }); 592 | }); 593 | 594 | describe('integration', () => { 595 | it('should successfully convert a simple 4-layer board (issue #33)', () => { 596 | const input = { 597 | head: { 598 | docType: '3', 599 | editorVersion: '6.4.3', 600 | newgId: true, 601 | c_para: {}, 602 | hasIdFlag: true, 603 | x: '4020', 604 | y: '3438.5', 605 | importFlag: 0, 606 | transformList: '', 607 | }, 608 | canvas: '', 609 | shape: ['TRACK~0.6~22~S$3354~4344.6 3172.8 4344.5 3225~gge2291~0'], 610 | layers: [] as string[], 611 | objects: [] as string[], 612 | BBox: { x: 4246, y: 3014, width: 227.5, height: 251 }, 613 | preference: { hideFootprints: '', hideNets: '' }, 614 | DRCRULE: { 615 | Default: { 616 | trackWidth: 1, 617 | clearance: 0.6, 618 | viaHoleDiameter: 2.4, 619 | viaHoleD: 1.2, 620 | }, 621 | isRealtime: true, 622 | isDrcOnRoutingOrPlaceVia: false, 623 | checkObjectToCopperarea: true, 624 | showDRCRangeLine: true, 625 | }, 626 | netColors: {}, 627 | }; 628 | expect(convertBoardToArray(input)).toEqual([ 629 | 'kicad_pcb', 630 | ['version', 20171130], 631 | ['host', 'pcbnew', '(5.1.5)-3'], 632 | ['page', 'A4'], 633 | [ 634 | 'layers', 635 | [0, 'F.Cu', 'signal'], 636 | [1, 'In1.Cu', 'signal'], 637 | [2, 'In2.Cu', 'signal'], 638 | [31, 'B.Cu', 'signal'], 639 | [32, 'B.Adhes', 'user'], 640 | [33, 'F.Adhes', 'user'], 641 | [34, 'B.Paste', 'user'], 642 | [35, 'F.Paste', 'user'], 643 | [36, 'B.SilkS', 'user'], 644 | [37, 'F.SilkS', 'user'], 645 | [38, 'B.Mask', 'user'], 646 | [39, 'F.Mask', 'user'], 647 | [40, 'Dwgs.User', 'user'], 648 | [41, 'Cmts.User', 'user'], 649 | [42, 'Eco1.User', 'user'], 650 | [43, 'Eco2.User', 'user'], 651 | [44, 'Edge.Cuts', 'user'], 652 | [45, 'Margin', 'user'], 653 | [46, 'B.CrtYd', 'user'], 654 | [47, 'F.CrtYd', 'user'], 655 | [48, 'B.Fab', 'user', 'hide'], 656 | [49, 'F.Fab', 'user', 'hide'], 657 | ], 658 | ['net', 0, ''], 659 | ['net', 1, 'S$3354'], 660 | [ 661 | 'segment', 662 | ['start', 87.52840000000009, 43.89120000000005], 663 | ['end', 87.503, 57.15], 664 | ['width', 0.15239999999999998], 665 | ['layer', 'In2.Cu'], 666 | ['net', 1], 667 | null, 668 | ], 669 | ]); 670 | }); 671 | }); 672 | -------------------------------------------------------------------------------- /src/board.ts: -------------------------------------------------------------------------------- 1 | import { IEasyEDABoard } from './easyeda-types'; 2 | import { encodeObject, ISpectraList } from './spectra'; 3 | import { computeArc } from './svg-arc'; 4 | 5 | // doc: https://docs.easyeda.com/en/DocumentFormat/3-EasyEDA-PCB-File-Format/index.html#shapes 6 | 7 | interface IConversionState { 8 | nets: string[]; 9 | innerLayers: number; 10 | } 11 | 12 | function getLayerName(id: string, conversionState: IConversionState) { 13 | const layers: { [key: string]: string } = { 14 | 1: 'F.Cu', 15 | 2: 'B.Cu', 16 | 3: 'F.SilkS', 17 | 4: 'B.SilkS', 18 | 5: 'F.Paste', 19 | 6: 'B.Paste', 20 | 7: 'F.Mask', 21 | 8: 'B.Mask', 22 | 10: 'Edge.Cuts', 23 | 11: 'Edge.Cuts', 24 | 12: 'Cmts.User', 25 | 13: 'F.Fab', 26 | 14: 'B.Fab', 27 | 15: 'Dwgs.User', 28 | }; 29 | if (id in layers) { 30 | return layers[id]; 31 | } 32 | 33 | // Inner layers: 21 -> In1.Cu 34 | const intId = parseInt(id, 10); 35 | if (intId >= 21 && intId <= 50) { 36 | const innerLayerId = intId - 20; 37 | conversionState.innerLayers = Math.max(conversionState.innerLayers, innerLayerId); 38 | return `In${innerLayerId}.Cu`; 39 | } 40 | 41 | if (intId >= 99 && intId < 200) { 42 | console.warn(`Warning: unsupported layer id: ${intId}`); 43 | return null; 44 | } 45 | 46 | throw new Error(`Missing layer id: ${id}`); 47 | } 48 | 49 | interface ICoordinates { 50 | x: number; 51 | y: number; 52 | } 53 | 54 | interface IParentTransform extends ICoordinates { 55 | angle: number | null; 56 | } 57 | 58 | function kiUnits(value: string | number) { 59 | if (typeof value === 'string') { 60 | value = parseFloat(value); 61 | } 62 | return value * 10 * 0.0254; 63 | } 64 | 65 | function kiAngle(value?: string, parentAngle?: number) { 66 | if (value) { 67 | const angle = parseFloat(value) + (parentAngle || 0); 68 | if (!isNaN(angle)) { 69 | return angle > 180 ? -(360 - angle) : angle; 70 | } 71 | } 72 | return null; 73 | } 74 | 75 | function rotate({ x, y }: ICoordinates, degrees: number) { 76 | const radians = (degrees / 180) * Math.PI; 77 | return { 78 | x: x * Math.cos(radians) - y * Math.sin(radians), 79 | y: x * Math.sin(radians) + y * Math.cos(radians), 80 | }; 81 | } 82 | 83 | function kiCoords( 84 | x: string, 85 | y: string, 86 | transform: IParentTransform = { x: 0, y: 0, angle: 0 } 87 | ): ICoordinates { 88 | return rotate( 89 | { 90 | x: kiUnits(parseFloat(x) - 4000) - transform.x, 91 | y: kiUnits(parseFloat(y) - 3000) - transform.y, 92 | }, 93 | transform.angle || 0 94 | ); 95 | } 96 | 97 | function kiAt(x: string, y: string, angle?: string, transform?: IParentTransform) { 98 | const coords = kiCoords(x, y, transform); 99 | return ['at', coords.x, coords.y, kiAngle(angle)]; 100 | } 101 | 102 | function kiStartEnd( 103 | startX: string, 104 | startY: string, 105 | endX: string, 106 | endY: string, 107 | parentCoords?: IParentTransform 108 | ) { 109 | const start = kiCoords(startX, startY, parentCoords); 110 | const end = kiCoords(endX, endY, parentCoords); 111 | return [ 112 | ['start', start.x, start.y], 113 | ['end', end.x, end.y], 114 | ]; 115 | } 116 | 117 | function isCopper(layerName: string) { 118 | return layerName.endsWith('.Cu'); 119 | } 120 | 121 | function getNetId({ nets }: IConversionState, netName: string) { 122 | if (!netName) { 123 | return -1; 124 | } 125 | const index = nets.indexOf(netName); 126 | if (index >= 0) { 127 | return index; 128 | } 129 | nets.push(netName); 130 | return nets.length - 1; 131 | } 132 | 133 | function convertVia( 134 | args: string[], 135 | conversionState: IConversionState, 136 | parentCoords?: IParentTransform 137 | ) { 138 | const [x, y, diameter, net, drill, id, locked] = args; 139 | return [ 140 | 'via', 141 | kiAt(x, y, undefined, parentCoords), 142 | ['size', kiUnits(diameter)], 143 | ['drill', kiUnits(drill) * 2], 144 | ['layers', 'F.Cu', 'B.Cu'], 145 | ['net', getNetId(conversionState, net)], 146 | ]; 147 | } 148 | 149 | function convertPadToVia( 150 | args: string[], 151 | conversionState: IConversionState, 152 | parentCoords?: IParentTransform 153 | ) { 154 | const [ 155 | shape, 156 | x, 157 | y, 158 | holeRadius, 159 | height, 160 | layerId, 161 | net, 162 | num, 163 | drill, 164 | points, 165 | rotation, 166 | id, 167 | holeLength, 168 | holePoints, 169 | plated, 170 | locked, 171 | ] = args; 172 | 173 | const size = kiUnits(holeRadius); 174 | const drillHoleLength = holeLength === '0' ? null : kiUnits(holeLength); 175 | 176 | if (shape !== 'ELLIPSE') { 177 | return [ 178 | 'module', 179 | 'AutoGenerated:Pad_' + size.toFixed(2) + 'mm', 180 | locked === '1' ? 'locked' : null, 181 | ['layer', 'F.Cu'], 182 | kiAt(x, y), 183 | ['attr', 'virtual'], 184 | ['fp_text', 'reference', '', ['at', 0, 0], ['layer', 'F.SilkS']], 185 | ['fp_text', 'value', '', ['at', 0, 0], ['layer', 'F.SilkS']], 186 | convertPad(args, conversionState, { ...kiCoords(x, y), angle: 0 }), 187 | ]; 188 | } 189 | 190 | return [ 191 | 'via', 192 | kiAt(x, y, undefined, parentCoords), 193 | ['size', kiUnits(holeRadius)], 194 | ['drill', kiUnits(drill) * 2], 195 | ['layers', 'F.Cu', 'B.Cu'], 196 | ['net', getNetId(conversionState, net)], 197 | ]; 198 | } 199 | 200 | function convertTrack( 201 | args: string[], 202 | conversionState: IConversionState, 203 | objName = 'segment', 204 | parentCoords?: IParentTransform 205 | ) { 206 | const [width, layer, net, coords, id, locked] = args; 207 | const netId = getNetId(conversionState, net); 208 | const coordList = coords.split(' '); 209 | const result = []; 210 | const layerName = getLayerName(layer, conversionState); 211 | if (!layerName) { 212 | return []; 213 | } 214 | 215 | const lineType = objName === 'segment' && !isCopper(layerName) ? 'gr_line' : objName; 216 | for (let i = 0; i < coordList.length - 2; i += 2) { 217 | result.push([ 218 | lineType, 219 | ...kiStartEnd( 220 | coordList[i], 221 | coordList[i + 1], 222 | coordList[i + 2], 223 | coordList[i + 3], 224 | parentCoords 225 | ), 226 | ['width', kiUnits(width)], 227 | ['layer', layerName], 228 | isCopper(layerName) && netId > 0 ? ['net', netId] : null, 229 | locked === '1' ? ['status', 40000] : null, 230 | ]); 231 | } 232 | return result; 233 | } 234 | 235 | function textLayer( 236 | layer: string, 237 | conversionState: IConversionState, 238 | footprint: boolean, 239 | isName: boolean 240 | ) { 241 | const layerName = getLayerName(layer, conversionState); 242 | if (layerName && footprint && isName) { 243 | return layerName.replace('.SilkS', '.Fab'); 244 | } else { 245 | return layerName; 246 | } 247 | } 248 | 249 | function convertText( 250 | args: string[], 251 | conversionState: IConversionState, 252 | objName = 'gr_text', 253 | parentCoords?: IParentTransform 254 | ) { 255 | const [ 256 | type, // N/P/L (Name/Prefix/Label) 257 | x, 258 | y, 259 | lineWidth, 260 | angle, 261 | mirror, 262 | layer, 263 | net, 264 | fontSize, 265 | text, 266 | path, 267 | display, 268 | id, 269 | font, 270 | locked, 271 | ] = args; 272 | const layerName = textLayer(layer, conversionState, objName === 'fp_text', type === 'N'); 273 | if (!layerName) { 274 | return null; 275 | } 276 | 277 | const fontTable: { [key: string]: { width: number; thickness: number } } = { 278 | 'NotoSerifCJKsc-Medium': { width: 0.8, thickness: 0.3 }, 279 | 'NotoSansCJKjp-DemiLight': { width: 0.6, thickness: 0.5 }, 280 | }; 281 | const fontMultiplier = font in fontTable ? fontTable[font] : { width: 1, thickness: 1 }; 282 | const actualFontSize = kiUnits(fontSize) * fontMultiplier.width; 283 | return [ 284 | objName, 285 | objName === 'fp_text' ? (type === 'P' ? 'reference' : 'value') : null, 286 | text, 287 | kiAt(x, y, angle, parentCoords), 288 | ['layer', layerName], 289 | display === 'none' ? 'hide' : null, 290 | [ 291 | 'effects', 292 | [ 293 | 'font', 294 | ['size', actualFontSize, actualFontSize], 295 | ['thickness', kiUnits(lineWidth) * fontMultiplier.thickness], 296 | ], 297 | ['justify', 'left', layerName[0] === 'B' ? 'mirror' : null], 298 | ], 299 | ]; 300 | } 301 | 302 | function convertArc( 303 | args: string[], 304 | conversionState: IConversionState, 305 | objName = 'gr_arc', 306 | transform?: IParentTransform 307 | ) { 308 | const [width, layer, net, path, _, id, locked] = args; 309 | const layerName = getLayerName(layer, conversionState); 310 | if (!layerName) { 311 | return null; 312 | } 313 | const pathMatch = /^M\s*([-\d.\s]+)A\s*([-\d.\s]+)$/.exec(path.replace(/[,\s]+/g, ' ')); 314 | if (!pathMatch) { 315 | console.warn(`Invalid arc path: ${path}`); 316 | return null; 317 | } 318 | const [match, startPoint, arcParams] = pathMatch; 319 | const [startX, startY] = startPoint.split(' '); 320 | const [svgRx, svgRy, xAxisRotation, largeArc, sweep, endX, endY] = arcParams.split(' '); 321 | const start = kiCoords(startX, startY, transform); 322 | const end = kiCoords(endX, endY, transform); 323 | const { x: rx, y: ry } = rotate({ x: kiUnits(svgRx), y: kiUnits(svgRy) }, transform?.angle || 0); 324 | const { cx, cy, extent } = computeArc( 325 | start.x, 326 | start.y, 327 | rx, 328 | ry, 329 | parseFloat(xAxisRotation), 330 | largeArc === '1', 331 | sweep === '1', 332 | end.x, 333 | end.y 334 | ); 335 | const endPoint = sweep === '1' ? start : end; 336 | if (isNaN(cx) || isNaN(cy)) { 337 | console.warn(`Invalid arc: ${path}`); 338 | return null; 339 | } 340 | return [ 341 | objName, 342 | ['start', cx, cy], // actually center 343 | ['end', endPoint.x, endPoint.y], 344 | ['angle', Math.abs(extent)], 345 | ['width', kiUnits(width)], 346 | ['layer', layerName], 347 | ]; 348 | } 349 | 350 | function getDrill(holeRadius: number, holeLength: number) { 351 | if (holeRadius && holeLength) { 352 | return ['drill', 'oval', holeRadius * 2, holeLength]; 353 | } 354 | if (holeRadius) { 355 | return ['drill', holeRadius * 2]; 356 | } 357 | return null; 358 | } 359 | 360 | function isRectangle(points: number[]) { 361 | if (points.length !== 8) { 362 | return false; 363 | } 364 | 365 | const eq = (a: number, b: number) => Math.abs(a - b) < 0.01; 366 | 367 | const [x1, y1, x2, y2, x3, y3, x4, y4] = points; 368 | return ( 369 | (eq(x1, x2) && eq(y2, y3) && eq(x3, x4) && eq(y4, y1)) || 370 | (eq(y1, y2) && eq(x2, x3) && eq(y3, y4) && eq(x4, x1)) 371 | ); 372 | } 373 | 374 | function rectangleSize(points: number[], rotation: number) { 375 | const [x1, y1, x2, y2, x3, y3, x4, y4] = points; 376 | const width = Math.max(x1, x2, x3, x4) - Math.min(x1, x2, x3, x4); 377 | const height = Math.max(y1, y2, y3, y4) - Math.min(y1, y2, y3, y4); 378 | return Math.round(Math.abs(rotation)) % 180 === 90 ? [height, width] : [width, height]; 379 | } 380 | 381 | function convertPad( 382 | args: string[], 383 | conversionState: IConversionState, 384 | transform: IParentTransform 385 | ) { 386 | const [ 387 | shape, 388 | x, 389 | y, 390 | width, 391 | height, 392 | layerId, 393 | net, 394 | num, 395 | holeRadius, 396 | points, 397 | rotation, 398 | id, 399 | holeLength, 400 | holePoints, 401 | plated, 402 | locked, 403 | ] = args; 404 | 405 | const padShapes: { [key: string]: string } = { 406 | ELLIPSE: 'circle', 407 | RECT: 'rect', 408 | OVAL: 'oval', 409 | POLYGON: 'custom', 410 | }; 411 | const centerCoords = kiCoords(x, y); 412 | const polygonTransform: IParentTransform = { 413 | x: centerCoords.x, 414 | y: centerCoords.y, 415 | angle: parseFloat(rotation), 416 | }; 417 | const pointList = points.split(' ').map(parseFloat); 418 | const pointsAreRectangle = padShapes[shape] === 'custom' && isRectangle(pointList); 419 | const actualShape = pointsAreRectangle ? 'RECT' : shape; 420 | const isCustomShape = padShapes[actualShape] === 'custom'; 421 | if (isCustomShape && !points.length) { 422 | console.warn(`PAD ${id} is a polygon, but has no points defined`); 423 | return null; 424 | } 425 | 426 | const netId = getNetId(conversionState, net); 427 | const layers: { [key: string]: string[] } = { 428 | 1: ['F.Cu', 'F.Paste', 'F.Mask'], 429 | 2: ['B.Cu', 'B.Paste', 'B.Mask'], 430 | 11: ['*.Cu', '*.Paste', '*.Mask'], 431 | }; 432 | const [actualWidth, actualHeight] = pointsAreRectangle 433 | ? rectangleSize(pointList, parseFloat(rotation)) 434 | : [width, height]; 435 | const padNum = parseInt(num, 10); 436 | return [ 437 | 'pad', 438 | isNaN(padNum) ? num : padNum, 439 | kiUnits(holeRadius) > 0 ? 'thru_hole' : 'smd', 440 | padShapes[actualShape], 441 | kiAt(x, y, rotation, transform), 442 | ['size', Math.max(kiUnits(actualWidth), 0.01), Math.max(kiUnits(actualHeight), 0.01)], 443 | ['layers', ...layers[layerId]], 444 | getDrill(kiUnits(holeRadius), kiUnits(holeLength)), 445 | netId > 0 ? ['net', netId, net] : null, 446 | isCustomShape 447 | ? [ 448 | 'primitives', 449 | [ 450 | 'gr_poly', 451 | ['pts', ...pointListToPolygon(points.split(' '), polygonTransform)], 452 | ['width', 0.1], 453 | ], 454 | ] 455 | : null, 456 | ]; 457 | } 458 | 459 | function convertLibHole(args: string[], transform: IParentTransform) { 460 | const [x, y, radius, id, locked] = args; 461 | const size = kiUnits(radius) * 2; 462 | return [ 463 | 'pad', 464 | '', 465 | 'np_thru_hole', 466 | 'circle', 467 | kiAt(x, y, undefined, transform), 468 | ['size', size, size], 469 | ['drill', size], 470 | ['layers', '*.Cu', '*.Mask'], 471 | ]; 472 | } 473 | 474 | function convertCircle( 475 | args: string[], 476 | conversionState: IConversionState, 477 | type = 'gr_circle', 478 | parentCoords?: IParentTransform 479 | ) { 480 | const [x, y, radius, strokeWidth, layer, id, locked] = args; 481 | const layerName = getLayerName(layer, conversionState); 482 | if (!layerName) { 483 | return null; 484 | } 485 | const center = kiCoords(x, y, parentCoords); 486 | return [ 487 | type, 488 | ['center', center.x, center.y], 489 | ['end', center.x + kiUnits(radius), center.y], 490 | ['layer', layerName], 491 | ['width', kiUnits(strokeWidth)], 492 | ]; 493 | } 494 | 495 | function pointListToPolygon(points: string[], parentCoords?: IParentTransform) { 496 | const polygonPoints = []; 497 | for (let i = 0; i < points.length; i += 2) { 498 | const coords = kiCoords(points[i], points[i + 1], parentCoords); 499 | polygonPoints.push(['xy', coords.x, coords.y]); 500 | } 501 | return polygonPoints; 502 | } 503 | 504 | function pathToPolygon(path: string, parentCoords?: IParentTransform) { 505 | if (path.indexOf('A') >= 0) { 506 | console.warn('Warning: SOLIDREGION with arcs/circles are not supported yet!'); 507 | return null; 508 | } 509 | const points = path.split(/[ ,LM]/).filter((p) => !isNaN(parseFloat(p))); 510 | return pointListToPolygon(points, parentCoords); 511 | } 512 | 513 | function convertPolygon( 514 | args: string[], 515 | conversionState: IConversionState, 516 | parentCoords?: IParentTransform 517 | ) { 518 | const [layerId, net, path, type, id, , , locked] = args; 519 | if (type !== 'solid') { 520 | console.warn(`Warning: unsupported SOLIDREGION type in footprint: ${type}`); 521 | return null; 522 | } 523 | const layerName = getLayerName(layerId, conversionState); 524 | if (!layerName) { 525 | return null; 526 | } 527 | const polygonPoints = pathToPolygon(path, parentCoords); 528 | if (!polygonPoints) { 529 | return null; 530 | } 531 | return ['fp_poly', ['pts', ...polygonPoints], ['layer', layerName], ['width', 0]]; 532 | } 533 | 534 | function convertLib(args: string[], conversionState: IConversionState) { 535 | const [x, y, attributes, rotation, importFlag, id, , , , locked] = args; 536 | const shapeList = args.join('~').split('#@$').slice(1); 537 | const attrList = attributes.split('`'); 538 | const attrs: { [key: string]: string } = {}; 539 | for (let i = 0; i < attrList.length; i += 2) { 540 | attrs[attrList[i]] = attrList[i + 1]; 541 | } 542 | const shapes = []; 543 | const transform = { ...kiCoords(x, y), angle: kiAngle(rotation) }; 544 | for (const shape of shapeList) { 545 | const [type, ...shapeArgs] = shape.split('~'); 546 | if (type === 'TRACK') { 547 | shapes.push(...convertTrack(shapeArgs, conversionState, 'fp_line', transform)); 548 | } else if (type === 'TEXT') { 549 | shapes.push(convertText(shapeArgs, conversionState, 'fp_text', transform)); 550 | } else if (type === 'ARC') { 551 | shapes.push(convertArc(shapeArgs, conversionState, 'fp_arc', transform)); 552 | } else if (type === 'HOLE') { 553 | shapes.push(convertLibHole(shapeArgs, transform)); 554 | } else if (type === 'PAD') { 555 | shapes.push(convertPad(shapeArgs, conversionState, transform)); 556 | } else if (type === 'CIRCLE') { 557 | shapes.push(convertCircle(shapeArgs, conversionState, 'fp_circle', transform)); 558 | } else if (type === 'SOLIDREGION') { 559 | shapes.push(convertPolygon(shapeArgs, conversionState, transform)); 560 | } else { 561 | console.warn(`Warning: unsupported shape ${type} in footprint ${id}`); 562 | } 563 | } 564 | shapes.push([ 565 | 'fp_text', 566 | 'user', 567 | id, 568 | ['at', 0, 0], 569 | ['layer', 'Cmts.User'], 570 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 571 | ]); 572 | 573 | const modAttrs = []; 574 | const isSmd = shapes.some((shape) => shape && shape[0] === 'pad' && shape[2] === 'smd'); 575 | if (isSmd) { 576 | modAttrs.push(['attr', 'smd']); 577 | } 578 | 579 | const footprintName = `easyeda:${attrs.package || id}`; 580 | return [ 581 | 'module', 582 | footprintName, 583 | locked === '1' ? 'locked' : null, 584 | ['layer', 'F.Cu'], 585 | kiAt(x, y, rotation), 586 | ...modAttrs, 587 | ...shapes, 588 | ]; 589 | } 590 | 591 | function convertCopperArea(args: string[], conversionState: IConversionState) { 592 | const [ 593 | strokeWidth, 594 | layerId, 595 | net, 596 | path, 597 | clearanceWidth, 598 | fillStyle, 599 | id, 600 | thermal, 601 | keepIsland, 602 | copperZone, 603 | locked, 604 | ] = args; 605 | const netId = getNetId(conversionState, net); 606 | const layerName = getLayerName(layerId, conversionState); 607 | if (!layerName) { 608 | return null; 609 | } 610 | // fill style: solid/none 611 | // id: gge27 612 | // thermal: spoke/direct 613 | const pointList = path.split(/[ ,LM]/).filter((p) => !isNaN(parseFloat(p))); 614 | const polygonPoints = []; 615 | for (let i = 0; i < pointList.length; i += 2) { 616 | const coords = kiCoords(pointList[i], pointList[i + 1]); 617 | polygonPoints.push(['xy', coords.x, coords.y]); 618 | } 619 | return [ 620 | 'zone', 621 | ['net', netId], 622 | ['net_name', net], 623 | ['layer', layerName], 624 | ['hatch', 'edge', 0.508], 625 | ['connect_pads', ['clearance', kiUnits(clearanceWidth)]], 626 | // TODO (min_thickness 0.254) 627 | // TODO (fill yes (arc_segments 32) (thermal_gap 0.508) (thermal_bridge_width 0.508)) 628 | ['polygon', ['pts', ...polygonPoints]], 629 | ]; 630 | } 631 | 632 | function convertSolidRegion(args: string[], conversionState: IConversionState) { 633 | const [layerId, net, path, type, id, locked] = args; 634 | const layerName = getLayerName(layerId, conversionState); 635 | if (!layerName) { 636 | return null; 637 | } 638 | const polygonPoints = pathToPolygon(path); 639 | const netId = getNetId(conversionState, net); 640 | if (!polygonPoints) { 641 | return null; 642 | } 643 | switch (type) { 644 | case 'cutout': 645 | return [ 646 | 'zone', 647 | ['net', netId], 648 | ['net_name', ''], 649 | ['hatch', 'edge', 0.508], 650 | ['layer', layerName], 651 | ['keepout', ['tracks', 'allowed'], ['vias', 'allowed'], ['copperpour', 'not_allowed']], 652 | ['polygon', ['pts', ...polygonPoints]], 653 | ]; 654 | 655 | case 'solid': 656 | case 'npth': 657 | return [ 658 | 'gr_poly', 659 | // Unfortunately, KiCad does not support net for gr_poly 660 | // ['net', netId], 661 | ['pts', ...polygonPoints], 662 | ['layer', layerName], 663 | ['width', 0], 664 | ]; 665 | 666 | default: 667 | console.warn(`Warning: unsupported SOLIDREGION type ${type}`); 668 | return null; 669 | } 670 | } 671 | 672 | function convertHole(args: string[]) { 673 | const [x, y, radius, id, locked] = args; 674 | const size = kiUnits(radius) * 2; 675 | return [ 676 | 'module', 677 | `AutoGenerated:MountingHole_${size.toFixed(2)}mm`, 678 | locked === '1' ? 'locked' : null, 679 | ['layer', 'F.Cu'], 680 | kiAt(x, y), 681 | ['attr', 'virtual'], 682 | ['fp_text', 'reference', '', ['at', 0, 0], ['layer', 'F.SilkS']], 683 | ['fp_text', 'value', '', ['at', 0, 0], ['layer', 'F.SilkS']], 684 | [ 685 | 'pad', 686 | '', 687 | 'np_thru_hole', 688 | 'circle', 689 | ['at', 0, 0], 690 | ['size', size, size], 691 | ['drill', size], 692 | ['layers', '*.Cu', '*.Mask'], 693 | ], 694 | ]; 695 | } 696 | 697 | export function convertShape(shape: string, conversionState: IConversionState) { 698 | const [type, ...args] = shape.split('~'); 699 | switch (type) { 700 | case 'VIA': 701 | return [convertVia(args, conversionState)]; 702 | case 'TRACK': 703 | return convertTrack(args, conversionState); 704 | case 'TEXT': 705 | return [convertText(args, conversionState)]; 706 | case 'ARC': 707 | return [convertArc(args, conversionState)]; 708 | case 'COPPERAREA': 709 | return [convertCopperArea(args, conversionState)]; 710 | case 'SOLIDREGION': 711 | return [convertSolidRegion(args, conversionState)]; 712 | case 'CIRCLE': 713 | return [convertCircle(args, conversionState)]; 714 | case 'HOLE': 715 | return [convertHole(args)]; 716 | case 'LIB': 717 | return [convertLib(args, conversionState)]; 718 | case 'PAD': 719 | return [convertPadToVia(args, conversionState)]; 720 | default: 721 | console.warn(`Warning: unsupported shape ${type}`); 722 | return null; 723 | } 724 | } 725 | 726 | function flatten(arr: T[]) { 727 | return ([] as T[]).concat(...arr); 728 | } 729 | 730 | export function convertBoardToArray(input: IEasyEDABoard): ISpectraList { 731 | const { nets } = input.routerRule || { nets: [] as string[] }; 732 | const conversionState = { nets, innerLayers: 0 }; 733 | nets.unshift(''); // Kicad expects net 0 to be empty 734 | const shapes = flatten(input.shape.map((shape) => convertShape(shape, conversionState))); 735 | const outputObjs = [...nets.map((net, idx) => ['net', idx, net]), ...shapes].filter( 736 | (obj) => obj != null 737 | ); 738 | 739 | const innerLayers = []; 740 | for (let i = 1; i <= conversionState.innerLayers; i++) { 741 | innerLayers.push([i, `In${i}.Cu`, 'signal']); 742 | } 743 | 744 | const layers = [ 745 | [0, 'F.Cu', 'signal'], 746 | ...innerLayers, 747 | [31, 'B.Cu', 'signal'], 748 | [32, 'B.Adhes', 'user'], 749 | [33, 'F.Adhes', 'user'], 750 | [34, 'B.Paste', 'user'], 751 | [35, 'F.Paste', 'user'], 752 | [36, 'B.SilkS', 'user'], 753 | [37, 'F.SilkS', 'user'], 754 | [38, 'B.Mask', 'user'], 755 | [39, 'F.Mask', 'user'], 756 | [40, 'Dwgs.User', 'user'], 757 | [41, 'Cmts.User', 'user'], 758 | [42, 'Eco1.User', 'user'], 759 | [43, 'Eco2.User', 'user'], 760 | [44, 'Edge.Cuts', 'user'], 761 | [45, 'Margin', 'user'], 762 | [46, 'B.CrtYd', 'user'], 763 | [47, 'F.CrtYd', 'user'], 764 | [48, 'B.Fab', 'user', 'hide'], 765 | [49, 'F.Fab', 'user', 'hide'], 766 | ]; 767 | 768 | return [ 769 | 'kicad_pcb', 770 | ['version', 20171130], 771 | ['host', 'pcbnew', '(5.1.5)-3'], 772 | ['page', 'A4'], 773 | ['layers', ...layers], 774 | ...outputObjs, 775 | ]; 776 | } 777 | 778 | export function convertBoard(board: IEasyEDABoard) { 779 | return encodeObject(convertBoardToArray(board)); 780 | } 781 | -------------------------------------------------------------------------------- /src/easyeda-types.ts: -------------------------------------------------------------------------------- 1 | // Generated by https://quicktype.io 2 | 3 | export interface IEasyEDASchematicCollection { 4 | schematics: Array<{ dataStr: string }>; 5 | } 6 | 7 | export interface IEasyEDASchematic { 8 | head: Head; 9 | canvas: string; 10 | shape: string[]; 11 | BBox: BBox; 12 | colors: {}; 13 | } 14 | 15 | export interface IEasyEDABoard { 16 | head: Head; 17 | canvas: string; 18 | shape: string[]; 19 | systemColor?: string; 20 | layers: string[]; 21 | objects: string[]; 22 | BBox: BBox; 23 | preference: Preference; 24 | DRCRULE: Drcrule; 25 | routerRule?: RouterRule; 26 | netColors: {}; 27 | } 28 | 29 | export interface BBox { 30 | x: number; 31 | y: number; 32 | width: number; 33 | height: number; 34 | } 35 | 36 | export interface Drcrule { 37 | Default: Default; 38 | isRealtime: boolean; 39 | checkObjectToCopperarea: boolean; 40 | showDRCRangeLine: boolean; 41 | } 42 | 43 | export interface Default { 44 | trackWidth: number; 45 | clearance: number; 46 | viaHoleDiameter: number; 47 | viaHoleD: number; 48 | } 49 | 50 | export interface Head { 51 | docType: string; 52 | editorVersion: string; 53 | c_para: {}; 54 | hasIdFlag: boolean; 55 | x: string; 56 | y: string; 57 | importFlag: number; 58 | transformList: string; 59 | } 60 | 61 | export interface Preference { 62 | hideFootprints: string; 63 | hideNets: string; 64 | } 65 | 66 | export interface RouterRule { 67 | unit: string; 68 | trackWidth: number; 69 | trackClearance: number; 70 | viaHoleD: number; 71 | viaDiameter: number; 72 | routerLayers: number[]; 73 | smdClearance: number; 74 | specialNets: any[]; 75 | nets: string[]; 76 | padsCount: number; 77 | skipNets: any[]; 78 | realtime: boolean; 79 | } 80 | -------------------------------------------------------------------------------- /src/fixtures/testingEasyEDA.json: -------------------------------------------------------------------------------- 1 | { 2 | "editorVersion": "6.2.46", 3 | "docType": "5", 4 | "title": "testingEasyEDA", 5 | "description": "", 6 | "colors": {}, 7 | "schematics": [ 8 | { 9 | "docType": "1", 10 | "title": "Sheet_1", 11 | "description": "", 12 | "dataStr": "{\"head\":{\"docType\":\"1\",\"editorVersion\":\"6.2.46\",\"c_para\":{\"Prefix Start\":\"1\"},\"c_spiceCmd\":null},\"canvas\":\"CA~1000~1000~#FFFFFF~yes~#CCCCCC~5~1000~1000~line~5~pixel~5~0~0\",\"shape\":[\"W~505 255 490 255 490 285 505 285~#008800~1~0~none~gge80~0\",\"W~500 270 490 270~#008800~1~0~none~gge82~0\",\"W~525 285 520 285 510 285 510 270 525 270 525 255 510 255~#008800~1~0~none~gge89~0\",\"W~530 255 530 270 535 270 545 255~#008800~1~0~none~gge92~0\",\"W~530 285 530 270~#008800~1~0~none~gge100~0\",\"W~545 285 540 270 530 270~#008800~1~0~none~gge105~0\",\"J~530~270~2.5~#CC0000~gge101~0\",\"J~490~270~2.5~#CC0000~gge107~0\"],\"BBox\":{\"x\":487.5,\"y\":255,\"width\":57.5,\"height\":30},\"colors\":{}}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { convertSchematic } from './schematic'; 2 | export { convertBoard, convertBoardToArray } from './board'; 3 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as fs from 'fs'; 4 | import { convertBoard } from './board'; 5 | import { convertSchematic } from './schematic'; 6 | import { encodeObject } from './spectra'; 7 | 8 | if (process.argv.length < 3) { 9 | console.error(`Usage: ${process.argv[1]} [output.kicad_pcb]`); 10 | process.exit(1); 11 | } 12 | 13 | if (process.argv[2] === '-v') { 14 | // tslint:disable-next-line 15 | console.log(`Version ${require('../package.json').version}`); 16 | process.exit(0); 17 | } 18 | 19 | const inputFile = process.argv[2] === '-' ? '/dev/stdin' : process.argv[2]; 20 | 21 | const input = JSON.parse(fs.readFileSync(inputFile, 'utf-8')); 22 | const output = input.docType === '5' ? convertSchematic(input) : convertBoard(input); 23 | const outputFile = process.argv[3]; 24 | if (outputFile && outputFile !== '-') { 25 | fs.writeFileSync(outputFile, output); 26 | } else { 27 | process.stdout.write(output); 28 | } 29 | -------------------------------------------------------------------------------- /src/schematic.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fixtureJSON from './fixtures/testingEasyEDA.json'; 2 | import { convertSchematic, convertShape } from './schematic'; 3 | 4 | describe('convertShape', () => { 5 | it('should convert wires', () => { 6 | const result = convertShape('W~650 305 650 300 800 300 800 255~#FF0000~1~0~none~gge1263~0'); 7 | expect(result).toEqual([ 8 | 'Wire Wire Line', 9 | ' 7150 3355 7150 3300', 10 | 'Wire Wire Line', 11 | ' 7150 3300 8800 3300', 12 | 'Wire Wire Line', 13 | ' 8800 3300 8800 2805', 14 | ]); 15 | }); 16 | 17 | it('should convert junctions', () => { 18 | const result = convertShape('J~785~210~2.5~#CC0000~gge1272~0'); 19 | expect(result).toEqual(['Connection ~ 8635 2310']); 20 | }); 21 | 22 | it('should convert No-Connect Flag', () => { 23 | const result = convertShape( 24 | 'O~510~495~gge1388~M 506 491 L 514 499 M 514 491 L 506 499~#33cc33~0' 25 | ); 26 | expect(result).toEqual(['NoConn ~ 5610 5445']); 27 | }); 28 | 29 | it('should return an empty array given an unsupported command', () => { 30 | const result = convertShape('Karin~was~here'); 31 | expect(result).toEqual([]); 32 | }); 33 | }); 34 | 35 | describe('convertSchematic', () => { 36 | it('should convert an EasyEDA schematic file', () => { 37 | const schRes = convertSchematic(fixtureJSON); 38 | expect(schRes).toMatchSnapshot(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/schematic.ts: -------------------------------------------------------------------------------- 1 | // Doc: https://docs.easyeda.com/en/DocumentFormat/2-EasyEDA-Schematic-File-Format/index.html 2 | import { IEasyEDASchematic, IEasyEDASchematicCollection } from './easyeda-types'; 3 | 4 | function kiUnits(value: string | number) { 5 | if (typeof value === 'string') { 6 | value = parseFloat(value); 7 | } 8 | return value * 11; 9 | } 10 | 11 | function flatten(arr: T[]) { 12 | return ([] as T[]).concat(...arr); 13 | } 14 | 15 | function convertNoConnect(args: string[]) { 16 | const [pinDotX, pinDotY, id, pathStr, color, locked] = args; 17 | const kiUnitX = kiUnits(pinDotX); 18 | const kiUnitY = kiUnits(pinDotY); 19 | const result = []; 20 | result.push(`NoConn ~ ${kiUnitX} ${kiUnitY}`); 21 | 22 | return result; 23 | } 24 | 25 | function convertJunction(args: string[]) { 26 | const [pinDotX, pinDotY, junctionCircleRadius, fillColor, id, locked] = args; 27 | const kiUnitX = kiUnits(pinDotX); 28 | const kiUnitY = kiUnits(pinDotY); 29 | const result = []; 30 | result.push(`Connection ~ ${kiUnitX} ${kiUnitY}`); 31 | 32 | return result; 33 | } 34 | 35 | function convertWire(args: string[]) { 36 | const [points, strokeColor, strokeWidth, strokeStyle, fillColor, id, locked] = args; 37 | const coordList = points.split(' '); 38 | const result = []; 39 | for (let i = 0; i < coordList.length - 2; i += 2) { 40 | result.push('Wire Wire Line'); 41 | result.push( 42 | ' ' + 43 | coordList 44 | .slice(i, i + 4) 45 | .map(kiUnits) 46 | .join(' ') 47 | ); 48 | } 49 | return result; 50 | } 51 | 52 | export function convertShape(shape: string) { 53 | const [type, ...args] = shape.split('~'); 54 | switch (type) { 55 | case 'W': 56 | return convertWire(args); 57 | case 'J': 58 | return convertJunction(args); 59 | case 'O': 60 | return convertNoConnect(args); 61 | default: 62 | console.warn(`Warning: unsupported shape ${type}`); 63 | return []; 64 | } 65 | } 66 | 67 | export function convertSchematic(input: IEasyEDASchematicCollection) { 68 | const { schematics } = input; 69 | if (schematics.length !== 1) { 70 | console.warn(`Found ${schematics.length} schematics, converting only the first one.`); 71 | } 72 | 73 | const schematic = JSON.parse(schematics[0].dataStr) as IEasyEDASchematic; 74 | const shapeArray = schematic.shape; 75 | const shapeResult = flatten(shapeArray.map(convertShape)).join('\n'); 76 | 77 | return ` 78 | EESchema Schematic File Version 4 79 | EELAYER 30 0 80 | EELAYER END 81 | ${shapeResult} 82 | $EndSCHEMATC 83 | `.trim(); 84 | } 85 | -------------------------------------------------------------------------------- /src/spectra.spec.ts: -------------------------------------------------------------------------------- 1 | import { encodeObject, parseObject } from './spectra'; 2 | 3 | describe('spectra', () => { 4 | describe('encodeObject', () => { 5 | it('should encode the given object in Spectra format', () => { 6 | expect(encodeObject(['foo', 'bar'])).toEqual('(foo bar)'); 7 | }); 8 | 9 | it('should escape quotes in strings', () => { 10 | expect(encodeObject(['0.96" OLED'])).toEqual('("0.96\\" OLED")'); 11 | }); 12 | 13 | it('should ignore null values', () => { 14 | expect(encodeObject(['foo', null, 'bar'])).toEqual('(foo bar)'); 15 | }); 16 | }); 17 | 18 | describe('parseObject', () => { 19 | it('should parse a simple object', () => { 20 | expect(parseObject('(foo bar)')).toEqual(['foo', 'bar']); 21 | }); 22 | 23 | it('should parse nested objects with strings and numbers', () => { 24 | expect( 25 | parseObject( 26 | '(fp_text user gge464 (at 0 0) (layer "Cmts.User") (effects (font (size 1 1) (thickness 0.15)))))' 27 | ) 28 | ).toEqual([ 29 | 'fp_text', 30 | 'user', 31 | 'gge464', 32 | ['at', 0, 0], 33 | ['layer', 'Cmts.User'], 34 | ['effects', ['font', ['size', 1, 1], ['thickness', 0.15]]], 35 | ]); 36 | }); 37 | 38 | it('should parse embedded quotes in strings', () => { 39 | expect( 40 | parseObject(`(test "this string has embedded "" quotes inside" "this one doesn't")`) 41 | ).toEqual(['test', 'this string has embedded " quotes inside', `this one doesn't`]); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/spectra.ts: -------------------------------------------------------------------------------- 1 | export interface ISpectraList extends Array {} 2 | 3 | const WHITESPACE = [' ', '\t', '\r', '\n']; 4 | 5 | function notNull(value: TValue | null): value is TValue { 6 | return value !== null; 7 | } 8 | 9 | export function encodeString(str: string) { 10 | if (/^[a-z][a-z0-9_]+$/.test(str)) { 11 | return str; 12 | } 13 | return `"${str.replace(/"/g, '\\"')}"`; 14 | } 15 | 16 | function encodeNumber(value: number) { 17 | return (Math.round(value * 1000 + Number.EPSILON) / 1000).toString(); 18 | } 19 | 20 | export function encodeValue(value: ISpectraList | string | number) { 21 | if (typeof value === 'string') { 22 | return encodeString(value); 23 | } 24 | if (typeof value === 'number') { 25 | return encodeNumber(value); 26 | } 27 | return encodeObject(value); 28 | } 29 | 30 | export function encodeObject(object: ISpectraList): string { 31 | return '(' + object.filter(notNull).map(encodeValue).join(' ') + ')'; 32 | } 33 | 34 | function parseElement(input: string): [ISpectraList | string | number, number] { 35 | let idx = 0; 36 | while (WHITESPACE.includes(input[idx])) { 37 | idx++; 38 | } 39 | if (idx >= input.length) { 40 | throw new Error('Unexpected end of string'); 41 | } 42 | if (input[idx] === '(') { 43 | idx++; 44 | const result = []; 45 | while (input[idx] !== ')') { 46 | if (idx >= input.length) { 47 | throw new Error('Unexpected end of string'); 48 | } 49 | const [element, len] = parseElement(input.substr(idx)); 50 | result.push(element); 51 | idx += len; 52 | } 53 | return [result, idx + 1]; 54 | } else if (input[idx] === '"') { 55 | idx++; 56 | let result = ''; 57 | while (input[idx] !== '"') { 58 | result += input[idx]; 59 | idx++; 60 | if (input.substr(idx, 2) === '""') { 61 | result += '"'; 62 | idx += 2; 63 | } 64 | } 65 | return [result, idx + 1]; 66 | } else { 67 | let result = ''; 68 | while (![...WHITESPACE, '(', ')'].includes(input[idx])) { 69 | if (idx >= input.length) { 70 | throw new Error('Unexpected end of string'); 71 | } 72 | result += input[idx]; 73 | idx++; 74 | } 75 | const numVal = parseFloat(result); 76 | if (typeof numVal === 'number' && !isNaN(numVal)) { 77 | return [numVal, idx]; 78 | } else { 79 | return [result, idx]; 80 | } 81 | } 82 | } 83 | 84 | export function parseObject(spectra: string) { 85 | spectra = spectra.trim(); 86 | const [parsed] = parseElement(spectra); 87 | return parsed; 88 | } 89 | -------------------------------------------------------------------------------- /src/svg-arc.ts: -------------------------------------------------------------------------------- 1 | /* SVG Arc Utility function, based on Apache Batik code: 2 | * https://xmlgraphics.apache.org/batik/ 3 | * 4 | * Algorithm taken from org.apache.batik.ext.awt.geom.ExtendedGeneralPath::computeArc() 5 | * with slight adaptations. 6 | * 7 | * Original copyright notice follows. 8 | */ 9 | 10 | function toRadians(n: number) { 11 | return (n / 180) * Math.PI; 12 | } 13 | 14 | function toDegrees(n: number) { 15 | return (n / Math.PI) * 180; 16 | } 17 | 18 | /* 19 | Copyright 2001-2003 The Apache Software Foundation 20 | 21 | Licensed under the Apache License, Version 2.0 (the "License"); 22 | you may not use this file except in compliance with the License. 23 | You may obtain a copy of the License at 24 | 25 | http://www.apache.org/licenses/LICENSE-2.0 26 | 27 | Unless required by applicable law or agreed to in writing, software 28 | distributed under the License is distributed on an "AS IS" BASIS, 29 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 30 | See the License for the specific language governing permissions and 31 | limitations under the License. 32 | */ 33 | export function computeArc( 34 | x0: number, 35 | y0: number, 36 | rx: number, 37 | ry: number, 38 | angle: number, 39 | largeArcFlag: boolean, 40 | sweepFlag: boolean, 41 | x: number, 42 | y: number 43 | ) { 44 | // 45 | // Elliptical arc implementation based on the SVG specification notes 46 | // 47 | 48 | // Compute the half distance between the current and the final point 49 | const dx2 = (x0 - x) / 2.0; 50 | const dy2 = (y0 - y) / 2.0; 51 | // Convert angle from degrees to radians 52 | angle = toRadians(angle % 360.0); 53 | const cosAngle = Math.cos(angle); 54 | const sinAngle = Math.sin(angle); 55 | 56 | // 57 | // Step 1 : Compute (x1, y1) 58 | // 59 | const x1 = cosAngle * dx2 + sinAngle * dy2; 60 | const y1 = -sinAngle * dx2 + cosAngle * dy2; 61 | // Ensure radii are large enough 62 | rx = Math.abs(rx); 63 | ry = Math.abs(ry); 64 | let Prx = rx * rx; 65 | let Pry = ry * ry; 66 | const Px1 = x1 * x1; 67 | const Py1 = y1 * y1; 68 | // check that radii are large enough 69 | const radiiCheck = Px1 / Prx + Py1 / Pry; 70 | if (radiiCheck > 1) { 71 | rx = Math.sqrt(radiiCheck) * rx; 72 | ry = Math.sqrt(radiiCheck) * ry; 73 | Prx = rx * rx; 74 | Pry = ry * ry; 75 | } 76 | 77 | // 78 | // Step 2 : Compute (cx1, cy1) 79 | // 80 | let sign = largeArcFlag === sweepFlag ? -1 : 1; 81 | let sq = (Prx * Pry - Prx * Py1 - Pry * Px1) / (Prx * Py1 + Pry * Px1); 82 | sq = sq < 0 ? 0 : sq; 83 | const coef = sign * Math.sqrt(sq); 84 | const cx1 = coef * ((rx * y1) / ry); 85 | const cy1 = coef * -((ry * x1) / rx); 86 | 87 | // 88 | // Step 3 : Compute (cx, cy) from (cx1, cy1) 89 | // 90 | const sx2 = (x0 + x) / 2.0; 91 | const sy2 = (y0 + y) / 2.0; 92 | const cx = sx2 + (cosAngle * cx1 - sinAngle * cy1); 93 | const cy = sy2 + (sinAngle * cx1 + cosAngle * cy1); 94 | 95 | // 96 | // Step 4 : Compute the angleStart (angle1) and the angleExtent (dangle) 97 | // 98 | const ux = (x1 - cx1) / rx; 99 | const uy = (y1 - cy1) / ry; 100 | const vx = (-x1 - cx1) / rx; 101 | const vy = (-y1 - cy1) / ry; 102 | // Compute the angle start 103 | let n = Math.sqrt(ux * ux + uy * uy); 104 | let p = ux; // (1 * ux) + (0 * uy) 105 | sign = uy < 0 ? -1 : 1; 106 | let angleStart = toDegrees(sign * Math.acos(p / n)); 107 | 108 | // Compute the angle extent 109 | n = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)); 110 | p = ux * vx + uy * vy; 111 | sign = ux * vy - uy * vx < 0 ? -1 : 1; 112 | let angleExtent = toDegrees(sign * Math.acos(p / n)); 113 | if (!sweepFlag && angleExtent > 0) { 114 | angleExtent -= 360; 115 | } else if (sweepFlag && angleExtent < 0) { 116 | angleExtent += 360; 117 | } 118 | angleExtent %= 360; 119 | angleStart %= 360; 120 | 121 | // 122 | // We can now build the resulting Arc2D in double precision 123 | // 124 | return { 125 | cx, 126 | cy, 127 | width: rx * 2.0, 128 | height: ry * 2.0, 129 | start: angleStart, 130 | extent: angleExtent, 131 | }; 132 | } 133 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "removeComments": false, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "newLine": "LF", 11 | "noEmitOnError": true, 12 | "rootDir": "src", 13 | "outDir": "dist", 14 | "lib": ["es2015"], 15 | }, 16 | "include": ["src/**/*.ts"], 17 | "exclude": ["src/**/*.spec.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [], 4 | "compilerOptions": { 5 | "resolveJsonModule": true, 6 | "strictNullChecks": false, 7 | "noEmitOnError": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tslint.commit.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["./tslint.json"], 3 | "rules": { 4 | "ordered-imports": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "interface-name": false, 5 | "no-console": false, 6 | "object-literal-sort-keys": false, 7 | "ordered-imports": false, 8 | "quotemark": [true, "single"], 9 | "trailing-comma": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | return { 3 | files: ['src/**/*.ts', { pattern: 'src/**/*.spec.ts', ignore: true }, 'src/**/*.json'], 4 | 5 | tests: ['src/**/*.spec.ts'], 6 | 7 | env: { 8 | type: 'node', 9 | runner: 'node' 10 | }, 11 | 12 | testFramework: 'jest' 13 | }; 14 | }; 15 | --------------------------------------------------------------------------------