@jlcpcb/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +474 -0
  3. package/package.json +48 -0
  4. package/src/api/easyeda-community.ts +259 -0
  5. package/src/api/easyeda.ts +153 -0
  6. package/src/api/index.ts +7 -0
  7. package/src/api/jlc.ts +185 -0
  8. package/src/constants/design-rules.ts +119 -0
  9. package/src/constants/footprints.ts +68 -0
  10. package/src/constants/index.ts +7 -0
  11. package/src/constants/kicad.ts +147 -0
  12. package/src/converter/category-router.ts +638 -0
  13. package/src/converter/footprint-mapper.ts +236 -0
  14. package/src/converter/footprint.ts +949 -0
  15. package/src/converter/global-lib-table.ts +394 -0
  16. package/src/converter/index.ts +46 -0
  17. package/src/converter/lib-table.ts +181 -0
  18. package/src/converter/svg-arc.ts +179 -0
  19. package/src/converter/symbol-templates.ts +214 -0
  20. package/src/converter/symbol.ts +1682 -0
  21. package/src/converter/value-normalizer.ts +262 -0
  22. package/src/index.ts +25 -0
  23. package/src/parsers/easyeda-shapes.ts +628 -0
  24. package/src/parsers/http-client.ts +96 -0
  25. package/src/parsers/index.ts +38 -0
  26. package/src/parsers/utils.ts +29 -0
  27. package/src/services/component-service.ts +100 -0
  28. package/src/services/fix-service.ts +50 -0
  29. package/src/services/index.ts +9 -0
  30. package/src/services/library-service.ts +696 -0
  31. package/src/types/component.ts +61 -0
  32. package/src/types/easyeda-community.ts +78 -0
  33. package/src/types/easyeda.ts +356 -0
  34. package/src/types/index.ts +12 -0
  35. package/src/types/jlc.ts +84 -0
  36. package/src/types/kicad.ts +136 -0
  37. package/src/types/mcp.ts +77 -0
  38. package/src/types/project.ts +60 -0
  39. package/src/utils/conversion.ts +104 -0
  40. package/src/utils/file-system.ts +143 -0
  41. package/src/utils/index.ts +8 -0
  42. package/src/utils/logger.ts +96 -0
  43. package/src/utils/validation.ts +110 -0
  44. package/tsconfig.json +9 -0
@@ -0,0 +1,949 @@
1
+ /**
2
+ * EasyEDA Footprint to KiCad Footprint Converter
3
+ * Complete rewrite to handle all EasyEDA shape types
4
+ *
5
+ * Supported shapes: PAD, TRACK, HOLE, CIRCLE, ARC, RECT, VIA, TEXT, SOLIDREGION
6
+ */
7
+
8
+ import type {
9
+ EasyEDAComponentData,
10
+ EasyEDAPad,
11
+ EasyEDATrack,
12
+ EasyEDAHole,
13
+ EasyEDACircle,
14
+ EasyEDAArc,
15
+ EasyEDARect,
16
+ EasyEDAVia,
17
+ EasyEDAText,
18
+ EasyEDASolidRegion,
19
+ } from '../types/index.js';
20
+ import { KICAD_FOOTPRINT_VERSION, KICAD_LAYERS } from '../constants/index.js';
21
+ import { roundTo } from '../utils/index.js';
22
+ import { mapToKicadFootprint, getKicadFootprintRef } from './footprint-mapper.js';
23
+
24
+ // =============================================================================
25
+ // Constants - EasyEDA to KiCad mappings from easyeda2kicad.py
26
+ // =============================================================================
27
+
28
+ // EasyEDA uses 10mil units (0.254mm per unit)
29
+ const EE_TO_MM = 10 * 0.0254; // = 0.254
30
+
31
+ // General layer mapping for graphics (TRACK, CIRCLE, ARC, RECT, TEXT)
32
+ const KI_LAYERS: Record<number, string> = {
33
+ 1: 'F.Cu',
34
+ 2: 'B.Cu',
35
+ 3: 'F.SilkS',
36
+ 4: 'B.SilkS',
37
+ 5: 'F.Paste',
38
+ 6: 'B.Paste',
39
+ 7: 'F.Mask',
40
+ 8: 'B.Mask',
41
+ 10: 'Edge.Cuts',
42
+ 11: 'Edge.Cuts',
43
+ 12: 'Cmts.User',
44
+ 13: 'F.Fab',
45
+ 14: 'B.Fab',
46
+ 15: 'Dwgs.User',
47
+ 101: 'F.Fab',
48
+ };
49
+
50
+ // Layer mapping for SMD pads (includes paste layer)
51
+ const KI_PAD_LAYER_SMD: Record<number, string> = {
52
+ 1: '"F.Cu" "F.Paste" "F.Mask"',
53
+ 2: '"B.Cu" "B.Paste" "B.Mask"',
54
+ 11: '"*.Cu" "*.Paste" "*.Mask"',
55
+ };
56
+
57
+ // Layer mapping for THT pads (no paste layer)
58
+ const KI_PAD_LAYER_THT: Record<number, string> = {
59
+ 1: '"F.Cu" "F.Mask"',
60
+ 2: '"B.Cu" "B.Mask"',
61
+ 11: '"*.Cu" "*.Mask"',
62
+ };
63
+
64
+ // Pad shape mapping
65
+ const KI_PAD_SHAPE: Record<string, string> = {
66
+ ELLIPSE: 'circle',
67
+ RECT: 'rect',
68
+ OVAL: 'oval',
69
+ POLYGON: 'custom',
70
+ };
71
+
72
+ // =============================================================================
73
+ // Types
74
+ // =============================================================================
75
+
76
+ export interface FootprintConversionOptions {
77
+ libraryName?: string;
78
+ include3DModel?: boolean;
79
+ modelPath?: string;
80
+ }
81
+
82
+ export interface FootprintResult {
83
+ type: 'reference' | 'generated';
84
+ reference?: string;
85
+ content?: string;
86
+ name: string;
87
+ }
88
+
89
+ interface Point {
90
+ x: number;
91
+ y: number;
92
+ }
93
+
94
+ interface BoundingBox {
95
+ minX: number;
96
+ maxX: number;
97
+ minY: number;
98
+ maxY: number;
99
+ }
100
+
101
+ /**
102
+ * Calculate the geometric center of pads (in EasyEDA units, before conversion)
103
+ * Used to center the footprint at (0,0) in KiCad
104
+ */
105
+ function calculatePadCenter(pads: EasyEDAPad[]): Point {
106
+ if (pads.length === 0) {
107
+ return { x: 0, y: 0 };
108
+ }
109
+
110
+ let minX = Infinity,
111
+ maxX = -Infinity;
112
+ let minY = Infinity,
113
+ maxY = -Infinity;
114
+
115
+ for (const pad of pads) {
116
+ // Include pad size in bounds calculation
117
+ const hw = pad.width / 2;
118
+ const hh = pad.height / 2;
119
+ minX = Math.min(minX, pad.centerX - hw);
120
+ maxX = Math.max(maxX, pad.centerX + hw);
121
+ minY = Math.min(minY, pad.centerY - hh);
122
+ maxY = Math.max(maxY, pad.centerY + hh);
123
+ }
124
+
125
+ return {
126
+ x: (minX + maxX) / 2,
127
+ y: (minY + maxY) / 2,
128
+ };
129
+ }
130
+
131
+ // =============================================================================
132
+ // Helper functions
133
+ // =============================================================================
134
+
135
+ /**
136
+ * Convert EasyEDA coordinate to mm (relative to origin)
137
+ */
138
+ function toMM(value: number): number {
139
+ return value * EE_TO_MM;
140
+ }
141
+
142
+ /**
143
+ * Convert EasyEDA X coordinate (origin-relative, Y-flipped for KiCad)
144
+ */
145
+ function convertX(x: number, originX: number): number {
146
+ return roundTo((x - originX) * EE_TO_MM, 4);
147
+ }
148
+
149
+ /**
150
+ * Convert EasyEDA Y coordinate (origin-relative)
151
+ * Note: KiCad footprints use same Y convention as EasyEDA (Y positive going down)
152
+ */
153
+ function convertY(y: number, originY: number): number {
154
+ return roundTo((y - originY) * EE_TO_MM, 4);
155
+ }
156
+
157
+ /**
158
+ * Parse space-separated point string "x1 y1 x2 y2 ..." into Point array
159
+ */
160
+ function parsePoints(pointsStr: string): Point[] {
161
+ const values = pointsStr.trim().split(/\s+/).map(Number);
162
+ const points: Point[] = [];
163
+ for (let i = 0; i < values.length - 1; i += 2) {
164
+ points.push({ x: values[i], y: values[i + 1] });
165
+ }
166
+ return points;
167
+ }
168
+
169
+ /**
170
+ * Get KiCad layer name from EasyEDA layer ID
171
+ */
172
+ function getLayer(layerId: number): string {
173
+ return KI_LAYERS[layerId] || 'F.SilkS';
174
+ }
175
+
176
+ /**
177
+ * Get KiCad pad layers based on pad type and EasyEDA layer
178
+ */
179
+ function getPadLayers(layerId: number, isSmd: boolean): string {
180
+ if (isSmd) {
181
+ return KI_PAD_LAYER_SMD[layerId] || '"F.Cu" "F.Paste" "F.Mask"';
182
+ }
183
+ return KI_PAD_LAYER_THT[layerId] || '"*.Cu" "*.Mask"';
184
+ }
185
+
186
+ /**
187
+ * Parse SVG arc path and extract arc parameters
188
+ * Format: "M x1 y1 A rx ry rotation large_arc sweep x2 y2"
189
+ */
190
+ function parseSvgArcPath(
191
+ path: string,
192
+ originX: number,
193
+ originY: number
194
+ ): { start: Point; end: Point; mid: Point } | null {
195
+ try {
196
+ // Match the SVG arc command pattern
197
+ const pathMatch = path.match(
198
+ /M\s*([\d.-]+)\s*([\d.-]+)\s*A\s*([\d.-]+)\s*([\d.-]+)\s*([\d.-]+)\s*(\d)\s*(\d)\s*([\d.-]+)\s*([\d.-]+)/i
199
+ );
200
+
201
+ if (!pathMatch) return null;
202
+
203
+ const [, x1, y1, rx, ry, rotation, largeArc, sweep, x2, y2] = pathMatch.map(Number);
204
+
205
+ // Convert to KiCad coordinates
206
+ const start: Point = {
207
+ x: convertX(x1, originX),
208
+ y: convertY(y1, originY),
209
+ };
210
+ const end: Point = {
211
+ x: convertX(x2, originX),
212
+ y: convertY(y2, originY),
213
+ };
214
+
215
+ // Calculate midpoint on arc (simplified - uses center approximation)
216
+ const centerX = (x1 + x2) / 2;
217
+ const centerY = (y1 + y2) / 2;
218
+
219
+ // Offset midpoint perpendicular to chord based on arc direction
220
+ const chordLen = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
221
+ const sagitta = Math.min(rx, ry) * 0.5; // Approximation
222
+
223
+ // Normal vector to chord
224
+ const nx = -(y2 - y1) / chordLen;
225
+ const ny = (x2 - x1) / chordLen;
226
+
227
+ // Adjust direction based on sweep flag (0 = counter-clockwise, 1 = clockwise)
228
+ const direction = sweep === 1 ? 1 : -1;
229
+
230
+ const midX = centerX + nx * sagitta * direction;
231
+ const midY = centerY + ny * sagitta * direction;
232
+
233
+ const mid: Point = {
234
+ x: convertX(midX, originX),
235
+ y: convertY(midY, originY),
236
+ };
237
+
238
+ return { start, end, mid };
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ // =============================================================================
245
+ // Footprint Converter Class
246
+ // =============================================================================
247
+
248
+ export class FootprintConverter {
249
+ /**
250
+ * Convert EasyEDA component data to KiCad footprint format string
251
+ */
252
+ convert(component: EasyEDAComponentData, options: FootprintConversionOptions = {}): string {
253
+ const { info, footprint, model3d } = component;
254
+ const name = this.sanitizeName(footprint.name);
255
+ // Calculate geometric center from pads to center footprint at (0,0)
256
+ const origin = calculatePadCenter(footprint.pads);
257
+ const { include3DModel = false } = options;
258
+
259
+ // Calculate bounds first (needed for text positioning)
260
+ const bounds = this.calculateBounds(footprint, origin);
261
+
262
+ // Map footprint type for attr token
263
+ const attrType = footprint.type === 'tht' ? 'through_hole' : 'smd';
264
+
265
+ let output = `(footprint "${name}"
266
+ \t(version ${KICAD_FOOTPRINT_VERSION})
267
+ \t(generator "ai-eda-jlc-mcp")
268
+ \t(layer "${KICAD_LAYERS.F_CU}")
269
+ \t(descr "${this.escapeString(info.description || name)}")
270
+ \t(tags "${this.escapeString(info.category || 'component')}")
271
+ `;
272
+
273
+ // Add properties (with bounds for text positioning)
274
+ output += this.generateProperties(info, name, bounds);
275
+
276
+ // Add attributes
277
+ output += `\t(attr ${attrType})\n`;
278
+
279
+ // Generate all pads
280
+ for (const pad of footprint.pads) {
281
+ output += this.generatePad(pad, origin);
282
+ }
283
+
284
+ // Generate HOLEs as NPTH pads
285
+ for (const hole of footprint.holes) {
286
+ output += this.generateHole(hole, origin);
287
+ }
288
+
289
+ // Generate VIAs as through-hole pads (rare in footprints but possible)
290
+ for (const via of footprint.vias) {
291
+ output += this.generateVia(via, origin);
292
+ }
293
+
294
+ // Generate TRACKs as fp_line (silkscreen, fab, etc.)
295
+ for (const track of footprint.tracks) {
296
+ output += this.generateTrack(track, origin);
297
+ }
298
+
299
+ // Generate CIRCLEs as fp_circle
300
+ for (const circle of footprint.circles) {
301
+ output += this.generateCircle(circle, origin);
302
+ }
303
+
304
+ // Generate ARCs as fp_arc
305
+ for (const arc of footprint.arcs) {
306
+ output += this.generateArc(arc, origin);
307
+ }
308
+
309
+ // Generate RECTs as 4 fp_line elements
310
+ for (const rect of footprint.rects) {
311
+ output += this.generateRect(rect, origin);
312
+ }
313
+
314
+ // Generate TEXT elements (not REF/VAL - those are in properties)
315
+ for (const text of footprint.texts) {
316
+ output += this.generateText(text, origin);
317
+ }
318
+
319
+ // Generate SOLIDREGION elements as fp_poly
320
+ for (const solidRegion of footprint.solidRegions) {
321
+ output += this.generateSolidRegion(solidRegion, origin);
322
+ }
323
+
324
+ // Add fab reference text
325
+ output += this.generateFabReference();
326
+
327
+ // Add courtyard
328
+ output += this.generateCourtyard(bounds);
329
+
330
+ // Add embedded_fonts declaration
331
+ output += `\t(embedded_fonts no)\n`;
332
+
333
+ // Add 3D model reference if available
334
+ if (include3DModel && model3d && options.modelPath) {
335
+ output += this.generate3DModel(options.modelPath);
336
+ }
337
+
338
+ output += `)`;
339
+
340
+ return output;
341
+ }
342
+
343
+ /**
344
+ * Get footprint using hybrid approach:
345
+ * - Use KiCad standard footprint if available (for common packages)
346
+ * - Generate custom footprint if no standard mapping exists
347
+ */
348
+ getFootprint(
349
+ component: EasyEDAComponentData,
350
+ options: FootprintConversionOptions = {}
351
+ ): FootprintResult {
352
+ const { info, footprint } = component;
353
+ const packageName = footprint.name;
354
+ const prefix = info.prefix;
355
+
356
+ // Try to map to KiCad standard footprint
357
+ const mapping = mapToKicadFootprint(packageName, prefix, info.category, info.description);
358
+
359
+ if (mapping) {
360
+ return {
361
+ type: 'reference',
362
+ reference: getKicadFootprintRef(mapping),
363
+ name: mapping.footprint,
364
+ };
365
+ }
366
+
367
+ // Generate custom footprint from EasyEDA data
368
+ const content = this.convert(component, options);
369
+ const name = this.sanitizeName(footprint.name);
370
+
371
+ return {
372
+ type: 'generated',
373
+ content,
374
+ name,
375
+ };
376
+ }
377
+
378
+ // ===========================================================================
379
+ // Element generators
380
+ // ===========================================================================
381
+
382
+ /**
383
+ * Generate PAD element
384
+ * Handles all shapes: RECT, ELLIPSE, OVAL, POLYGON
385
+ */
386
+ private generatePad(pad: EasyEDAPad, origin: Point): string {
387
+ const x = convertX(pad.centerX, origin.x);
388
+ const y = convertY(pad.centerY, origin.y);
389
+ const w = roundTo(toMM(pad.width), 4);
390
+ const h = roundTo(toMM(pad.height), 4);
391
+ const rotation = pad.rotation || 0;
392
+
393
+ // Determine if SMD or THT based on hole radius
394
+ const isSmd = pad.holeRadius === 0;
395
+ const padType = isSmd ? 'smd' : 'thru_hole';
396
+ const layers = getPadLayers(pad.layerId, isSmd);
397
+
398
+ // Handle POLYGON (custom) pads
399
+ if (pad.shape === 'POLYGON' && pad.points) {
400
+ return this.generatePolygonPad(pad, origin, layers);
401
+ }
402
+
403
+ // Map shape
404
+ const shape = KI_PAD_SHAPE[pad.shape] || 'rect';
405
+ const kicadShape = isSmd && shape === 'rect' ? 'roundrect' : shape;
406
+
407
+ let output = `\t(pad "${pad.number}" ${padType} ${kicadShape}\n`;
408
+ output += `\t\t(at ${x} ${y}${rotation !== 0 ? ` ${rotation}` : ''})\n`;
409
+ output += `\t\t(size ${w} ${h})\n`;
410
+ output += `\t\t(layers ${layers})\n`;
411
+
412
+ // Add roundrect ratio for SMD rect pads
413
+ if (kicadShape === 'roundrect') {
414
+ output += `\t\t(roundrect_rratio 0.25)\n`;
415
+ }
416
+
417
+ // Add drill for THT pads
418
+ if (!isSmd) {
419
+ const drillDiameter = roundTo(toMM(pad.holeRadius * 2), 4);
420
+
421
+ // Check for slot (oval hole)
422
+ if (pad.holeLength && pad.holeLength > 0) {
423
+ const holeW = drillDiameter;
424
+ const holeH = roundTo(toMM(pad.holeLength), 4);
425
+ output += `\t\t(drill oval ${holeW} ${holeH})\n`;
426
+ } else {
427
+ output += `\t\t(drill ${drillDiameter})\n`;
428
+ }
429
+ }
430
+
431
+ output += `\t)\n`;
432
+ return output;
433
+ }
434
+
435
+ /**
436
+ * Generate custom POLYGON pad using gr_poly primitive
437
+ * Supports both SMD and through-hole polygon pads
438
+ */
439
+ private generatePolygonPad(pad: EasyEDAPad, origin: Point, layers: string): string {
440
+ const x = convertX(pad.centerX, origin.x);
441
+ const y = convertY(pad.centerY, origin.y);
442
+
443
+ // Parse polygon points
444
+ const points = parsePoints(pad.points);
445
+ if (points.length < 3) {
446
+ // Fallback to rect if not enough points
447
+ return this.generatePad({ ...pad, shape: 'RECT', points: '' }, origin);
448
+ }
449
+
450
+ // Determine if SMD or THT based on hole radius (same logic as generatePad)
451
+ const isSmd = pad.holeRadius === 0;
452
+ const padType = isSmd ? 'smd' : 'thru_hole';
453
+
454
+ // Convert points relative to pad center (no Y-flip - KiCad footprints use same Y convention)
455
+ const polyPoints = points.map((p) => ({
456
+ x: roundTo(toMM(p.x - pad.centerX), 2),
457
+ y: roundTo(toMM(p.y - pad.centerY), 2),
458
+ }));
459
+
460
+ // Custom/polygon pads - rotation handled by polygon points
461
+ let output = `\t(pad "${pad.number}" ${padType} custom\n`;
462
+ output += `\t\t(at ${x} ${y})\n`;
463
+ output += `\t\t(size 0.01 0.01)\n`;
464
+
465
+ // Add drill for through-hole pads
466
+ if (!isSmd) {
467
+ const drillDiameter = roundTo(toMM(pad.holeRadius * 2), 4);
468
+ if (pad.holeLength && pad.holeLength > 0) {
469
+ // Slot/oval hole
470
+ const holeH = roundTo(toMM(pad.holeLength), 4);
471
+ output += `\t\t(drill oval ${drillDiameter} ${holeH})\n`;
472
+ } else {
473
+ output += `\t\t(drill ${drillDiameter})\n`;
474
+ }
475
+ }
476
+
477
+ output += `\t\t(layers ${layers})\n`;
478
+ output += `\t\t(primitives\n`;
479
+ output += `\t\t\t(gr_poly\n`;
480
+ output += `\t\t\t\t(pts\n`;
481
+
482
+ for (const pt of polyPoints) {
483
+ output += `\t\t\t\t\t(xy ${pt.x} ${pt.y})\n`;
484
+ }
485
+
486
+ output += `\t\t\t\t)\n`;
487
+ output += `\t\t\t\t(width 0.1)\n`;
488
+ output += `\t\t\t)\n`;
489
+ output += `\t\t)\n`;
490
+ output += `\t)\n`;
491
+
492
+ return output;
493
+ }
494
+
495
+ /**
496
+ * Generate HOLE as NPTH pad
497
+ */
498
+ private generateHole(hole: EasyEDAHole, origin: Point): string {
499
+ const x = convertX(hole.centerX, origin.x);
500
+ const y = convertY(hole.centerY, origin.y);
501
+ const diameter = roundTo(toMM(hole.radius * 2), 4);
502
+
503
+ return `\t(pad "" np_thru_hole circle
504
+ \t\t(at ${x} ${y})
505
+ \t\t(size ${diameter} ${diameter})
506
+ \t\t(drill ${diameter})
507
+ \t\t(layers "*.Cu" "*.Mask")
508
+ \t)\n`;
509
+ }
510
+
511
+ /**
512
+ * Generate VIA as through-hole pad (no number)
513
+ */
514
+ private generateVia(via: EasyEDAVia, origin: Point): string {
515
+ const x = convertX(via.centerX, origin.x);
516
+ const y = convertY(via.centerY, origin.y);
517
+ const outerDiameter = roundTo(toMM(via.diameter), 4);
518
+ const drillDiameter = roundTo(toMM(via.radius * 2), 4);
519
+
520
+ return `\t(pad "" thru_hole circle
521
+ \t\t(at ${x} ${y})
522
+ \t\t(size ${outerDiameter} ${outerDiameter})
523
+ \t\t(drill ${drillDiameter})
524
+ \t\t(layers "*.Cu" "*.Mask")
525
+ \t)\n`;
526
+ }
527
+
528
+ /**
529
+ * Generate TRACK as fp_line segments
530
+ */
531
+ private generateTrack(track: EasyEDATrack, origin: Point): string {
532
+ const layer = getLayer(track.layerId);
533
+ const strokeWidth = roundTo(toMM(track.strokeWidth), 2);
534
+ const points = parsePoints(track.points);
535
+
536
+ if (points.length < 2) return '';
537
+
538
+ let output = '';
539
+ for (let i = 0; i < points.length - 1; i++) {
540
+ const x1 = convertX(points[i].x, origin.x);
541
+ const y1 = convertY(points[i].y, origin.y);
542
+ const x2 = convertX(points[i + 1].x, origin.x);
543
+ const y2 = convertY(points[i + 1].y, origin.y);
544
+
545
+ output += `\t(fp_line
546
+ \t\t(start ${x1} ${y1})
547
+ \t\t(end ${x2} ${y2})
548
+ \t\t(stroke
549
+ \t\t\t(width ${strokeWidth})
550
+ \t\t\t(type solid)
551
+ \t\t)
552
+ \t\t(layer "${layer}")
553
+ \t)\n`;
554
+ }
555
+
556
+ return output;
557
+ }
558
+
559
+ /**
560
+ * Generate CIRCLE as fp_circle
561
+ */
562
+ private generateCircle(circle: EasyEDACircle, origin: Point): string {
563
+ const cx = convertX(circle.cx, origin.x);
564
+ const cy = convertY(circle.cy, origin.y);
565
+ const r = roundTo(toMM(circle.radius), 4);
566
+ const strokeWidth = roundTo(toMM(circle.strokeWidth), 2);
567
+ const layer = getLayer(circle.layerId);
568
+
569
+ // KiCad fp_circle uses center and end point (point on circumference)
570
+ const endX = roundTo(cx + r, 4);
571
+
572
+ return `\t(fp_circle
573
+ \t\t(center ${cx} ${cy})
574
+ \t\t(end ${endX} ${cy})
575
+ \t\t(stroke
576
+ \t\t\t(width ${strokeWidth})
577
+ \t\t\t(type solid)
578
+ \t\t)
579
+ \t\t(layer "${layer}")
580
+ \t)\n`;
581
+ }
582
+
583
+ /**
584
+ * Generate ARC as fp_arc (from SVG path)
585
+ */
586
+ private generateArc(arc: EasyEDAArc, origin: Point): string {
587
+ const layer = getLayer(arc.layerId);
588
+ const strokeWidth = roundTo(toMM(arc.strokeWidth), 2);
589
+
590
+ const arcData = parseSvgArcPath(arc.path, origin.x, origin.y);
591
+ if (!arcData) return '';
592
+
593
+ const { start, end, mid } = arcData;
594
+
595
+ return `\t(fp_arc
596
+ \t\t(start ${start.x} ${start.y})
597
+ \t\t(mid ${mid.x} ${mid.y})
598
+ \t\t(end ${end.x} ${end.y})
599
+ \t\t(stroke
600
+ \t\t\t(width ${strokeWidth})
601
+ \t\t\t(type solid)
602
+ \t\t)
603
+ \t\t(layer "${layer}")
604
+ \t)\n`;
605
+ }
606
+
607
+ /**
608
+ * Generate RECT as 4 fp_line elements
609
+ */
610
+ private generateRect(rect: EasyEDARect, origin: Point): string {
611
+ const layer = getLayer(rect.layerId);
612
+ const strokeWidth = roundTo(toMM(rect.strokeWidth), 2);
613
+
614
+ const x1 = convertX(rect.x, origin.x);
615
+ const y1 = convertY(rect.y, origin.y);
616
+ const x2 = convertX(rect.x + rect.width, origin.x);
617
+ const y2 = convertY(rect.y + rect.height, origin.y);
618
+
619
+ // Generate 4 lines for rectangle
620
+ const lines = [
621
+ { start: [x1, y1], end: [x2, y1] }, // top
622
+ { start: [x2, y1], end: [x2, y2] }, // right
623
+ { start: [x2, y2], end: [x1, y2] }, // bottom
624
+ { start: [x1, y2], end: [x1, y1] }, // left
625
+ ];
626
+
627
+ let output = '';
628
+ for (const line of lines) {
629
+ output += `\t(fp_line
630
+ \t\t(start ${line.start[0]} ${line.start[1]})
631
+ \t\t(end ${line.end[0]} ${line.end[1]})
632
+ \t\t(stroke
633
+ \t\t\t(width ${strokeWidth})
634
+ \t\t\t(type solid)
635
+ \t\t)
636
+ \t\t(layer "${layer}")
637
+ \t)\n`;
638
+ }
639
+
640
+ return output;
641
+ }
642
+
643
+ /**
644
+ * Generate TEXT as fp_text (user text, not REF/VAL)
645
+ */
646
+ private generateText(text: EasyEDAText, origin: Point): string {
647
+ // Skip if not displayed or is reference/value placeholder
648
+ if (!text.isDisplayed) return '';
649
+ if (text.type === 'N' || text.type === 'P') return ''; // Netname or prefix
650
+
651
+ const x = convertX(text.centerX, origin.x);
652
+ const y = convertY(text.centerY, origin.y);
653
+ const layer = getLayer(text.layerId);
654
+ const fontSize = roundTo(toMM(text.fontSize), 2);
655
+ const rotation = text.rotation || 0;
656
+
657
+ // Determine text justification based on position relative to origin
658
+ // EasyEDA text coordinates are anchor points, not center points
659
+ // Left side text should extend right (justify left)
660
+ // Right side text should extend left (justify right)
661
+ let justify = '';
662
+ if (x < -0.5) {
663
+ justify = 'left'; // Text on left side, anchor at left edge
664
+ } else if (x > 0.5) {
665
+ justify = 'right'; // Text on right side, anchor at right edge
666
+ }
667
+
668
+ return `\t(fp_text user "${this.escapeString(text.text)}"
669
+ \t\t(at ${x} ${y}${rotation !== 0 ? ` ${rotation}` : ''})
670
+ \t\t(layer "${layer}")
671
+ \t\t(effects
672
+ \t\t\t(font
673
+ \t\t\t\t(size ${fontSize} ${fontSize})
674
+ \t\t\t\t(thickness ${roundTo(fontSize * 0.15, 2)})
675
+ \t\t\t)
676
+ ${justify ? `\t\t\t(justify ${justify})\n` : ''}\t\t)
677
+ \t)\n`;
678
+ }
679
+
680
+ /**
681
+ * Generate SOLIDREGION as fp_poly (filled polygon)
682
+ * Parses SVG path with M/L/Z commands and converts to KiCad polygon
683
+ */
684
+ private generateSolidRegion(region: EasyEDASolidRegion, origin: Point): string {
685
+ const layer = getLayer(region.layerId);
686
+ const points = this.parseSvgPathToPoints(region.path, origin);
687
+
688
+ if (points.length < 3) return '';
689
+
690
+ let output = `\t(fp_poly\n`;
691
+ output += `\t\t(pts\n`;
692
+
693
+ for (const pt of points) {
694
+ output += `\t\t\t(xy ${pt.x} ${pt.y})\n`;
695
+ }
696
+
697
+ output += `\t\t)\n`;
698
+ output += `\t\t(stroke\n`;
699
+ output += `\t\t\t(width 0)\n`;
700
+ output += `\t\t\t(type solid)\n`;
701
+ output += `\t\t)\n`;
702
+ output += `\t\t(fill solid)\n`;
703
+ output += `\t\t(layer "${layer}")\n`;
704
+ output += `\t)\n`;
705
+
706
+ return output;
707
+ }
708
+
709
+ /**
710
+ * Parse SVG path string (M/L/Z commands) to array of points
711
+ * Format: "M x1,y1 L x2,y2 L x3,y3 Z" or "M x1,y1 L x2,y2 ..."
712
+ */
713
+ private parseSvgPathToPoints(path: string, origin: Point): Point[] {
714
+ const points: Point[] = [];
715
+
716
+ // Match M and L commands with coordinates
717
+ // Supports both "M425,230" and "M 425 230" formats
718
+ const commandRegex = /([ML])\s*([\d.-]+)[,\s]+([\d.-]+)/gi;
719
+ let match;
720
+
721
+ while ((match = commandRegex.exec(path)) !== null) {
722
+ const x = parseFloat(match[2]);
723
+ const y = parseFloat(match[3]);
724
+
725
+ points.push({
726
+ x: convertX(x, origin.x),
727
+ y: convertY(y, origin.y),
728
+ });
729
+ }
730
+
731
+ return points;
732
+ }
733
+
734
+ // ===========================================================================
735
+ // Property and outline generation
736
+ // ===========================================================================
737
+
738
+ /**
739
+ * Calculate bounding box from all footprint elements
740
+ */
741
+ private calculateBounds(
742
+ footprint: EasyEDAComponentData['footprint'],
743
+ origin: Point
744
+ ): BoundingBox {
745
+ let minX = Infinity,
746
+ maxX = -Infinity;
747
+ let minY = Infinity,
748
+ maxY = -Infinity;
749
+
750
+ const updateBounds = (x: number, y: number, margin = 0) => {
751
+ minX = Math.min(minX, x - margin);
752
+ maxX = Math.max(maxX, x + margin);
753
+ minY = Math.min(minY, y - margin);
754
+ maxY = Math.max(maxY, y + margin);
755
+ };
756
+
757
+ // Include pads
758
+ for (const pad of footprint.pads) {
759
+ const x = convertX(pad.centerX, origin.x);
760
+ const y = convertY(pad.centerY, origin.y);
761
+ const hw = toMM(pad.width) / 2;
762
+ const hh = toMM(pad.height) / 2;
763
+ updateBounds(x, y, Math.max(hw, hh));
764
+ }
765
+
766
+ // Include holes
767
+ for (const hole of footprint.holes) {
768
+ const x = convertX(hole.centerX, origin.x);
769
+ const y = convertY(hole.centerY, origin.y);
770
+ const r = toMM(hole.radius);
771
+ updateBounds(x, y, r);
772
+ }
773
+
774
+ // Include tracks
775
+ for (const track of footprint.tracks) {
776
+ const points = parsePoints(track.points);
777
+ for (const pt of points) {
778
+ const x = convertX(pt.x, origin.x);
779
+ const y = convertY(pt.y, origin.y);
780
+ updateBounds(x, y);
781
+ }
782
+ }
783
+
784
+ // Include circles
785
+ for (const circle of footprint.circles) {
786
+ const x = convertX(circle.cx, origin.x);
787
+ const y = convertY(circle.cy, origin.y);
788
+ const r = toMM(circle.radius);
789
+ updateBounds(x, y, r);
790
+ }
791
+
792
+ // Handle empty case
793
+ if (!isFinite(minX)) {
794
+ return { minX: -1, maxX: 1, minY: -1, maxY: 1 };
795
+ }
796
+
797
+ return { minX, maxX, minY, maxY };
798
+ }
799
+
800
+ /**
801
+ * Generate footprint properties
802
+ * Positions Reference above courtyard and Value below courtyard
803
+ */
804
+ private generateProperties(info: EasyEDAComponentData['info'], name: string, bounds: BoundingBox): string {
805
+ let props = '';
806
+
807
+ // Calculate text positions based on courtyard bounds
808
+ // Add margin for courtyard (0.25) + text offset (1.5)
809
+ const textOffset = 1.75;
810
+ const refY = roundTo(bounds.minY - textOffset, 2);
811
+ const valY = roundTo(bounds.maxY + textOffset, 2);
812
+
813
+ // Reference (required, visible on silkscreen) - above courtyard
814
+ props += `\t(property "Reference" "REF**"
815
+ \t\t(at 0 ${refY} 0)
816
+ \t\t(layer "${KICAD_LAYERS.F_SILKS}")
817
+ \t\t(effects
818
+ \t\t\t(font
819
+ \t\t\t\t(size 1 1)
820
+ \t\t\t\t(thickness 0.15)
821
+ \t\t\t)
822
+ \t\t)
823
+ \t)\n`;
824
+
825
+ // Value (required, visible on fab layer) - below courtyard
826
+ props += `\t(property "Value" "${this.escapeString(this.sanitizeName(info.name))}"
827
+ \t\t(at 0 ${valY} 0)
828
+ \t\t(layer "${KICAD_LAYERS.F_FAB}")
829
+ \t\t(effects
830
+ \t\t\t(font
831
+ \t\t\t\t(size 1 1)
832
+ \t\t\t\t(thickness 0.15)
833
+ \t\t\t)
834
+ \t\t)
835
+ \t)\n`;
836
+
837
+ // Hidden properties
838
+ const hiddenProps: Array<{ key: string; value: string | undefined }> = [
839
+ { key: 'Description', value: info.description },
840
+ { key: 'LCSC', value: info.lcscId },
841
+ { key: 'Manufacturer', value: info.manufacturer },
842
+ ];
843
+
844
+ // Add component attributes
845
+ if (info.attributes) {
846
+ for (const [key, value] of Object.entries(info.attributes)) {
847
+ hiddenProps.push({ key, value: String(value) });
848
+ }
849
+ }
850
+
851
+ for (const { key, value } of hiddenProps) {
852
+ if (value) {
853
+ props += `\t(property "${this.escapeString(key)}" "${this.escapeString(value)}"
854
+ \t\t(at 0 0 0)
855
+ \t\t(layer "${KICAD_LAYERS.F_FAB}")
856
+ \t\thide
857
+ \t\t(effects
858
+ \t\t\t(font
859
+ \t\t\t\t(size 1.27 1.27)
860
+ \t\t\t\t(thickness 0.15)
861
+ \t\t\t)
862
+ \t\t)
863
+ \t)\n`;
864
+ }
865
+ }
866
+
867
+ return props;
868
+ }
869
+
870
+ /**
871
+ * Generate fab reference text
872
+ */
873
+ private generateFabReference(): string {
874
+ return `\t(fp_text user "\${REFERENCE}"
875
+ \t\t(at 0 0 0)
876
+ \t\t(layer "${KICAD_LAYERS.F_FAB}")
877
+ \t\t(effects
878
+ \t\t\t(font
879
+ \t\t\t\t(size 0.5 0.5)
880
+ \t\t\t\t(thickness 0.08)
881
+ \t\t\t)
882
+ \t\t)
883
+ \t)\n`;
884
+ }
885
+
886
+ /**
887
+ * Generate courtyard outline
888
+ */
889
+ private generateCourtyard(bounds: BoundingBox): string {
890
+ const margin = 0.25;
891
+ const minX = roundTo(bounds.minX - margin, 2);
892
+ const maxX = roundTo(bounds.maxX + margin, 2);
893
+ const minY = roundTo(bounds.minY - margin, 2);
894
+ const maxY = roundTo(bounds.maxY + margin, 2);
895
+
896
+ const lines = [
897
+ { start: [minX, minY], end: [maxX, minY] },
898
+ { start: [maxX, minY], end: [maxX, maxY] },
899
+ { start: [maxX, maxY], end: [minX, maxY] },
900
+ { start: [minX, maxY], end: [minX, minY] },
901
+ ];
902
+
903
+ let output = '';
904
+ for (const line of lines) {
905
+ output += `\t(fp_line
906
+ \t\t(start ${line.start[0]} ${line.start[1]})
907
+ \t\t(end ${line.end[0]} ${line.end[1]})
908
+ \t\t(stroke
909
+ \t\t\t(width 0.05)
910
+ \t\t\t(type solid)
911
+ \t\t)
912
+ \t\t(layer "${KICAD_LAYERS.F_CRTYD}")
913
+ \t)\n`;
914
+ }
915
+
916
+ return output;
917
+ }
918
+
919
+ /**
920
+ * Generate 3D model reference
921
+ */
922
+ private generate3DModel(modelPath: string): string {
923
+ return `\t(model "${modelPath}"
924
+ \t\t(offset
925
+ \t\t\t(xyz 0 0 0)
926
+ \t\t)
927
+ \t\t(scale
928
+ \t\t\t(xyz 1 1 1)
929
+ \t\t)
930
+ \t\t(rotate
931
+ \t\t\t(xyz 0 0 0)
932
+ \t\t)
933
+ \t)\n`;
934
+ }
935
+
936
+ // ===========================================================================
937
+ // Utility methods
938
+ // ===========================================================================
939
+
940
+ private sanitizeName(name: string): string {
941
+ return name.replace(/[^a-zA-Z0-9_.-]/g, '_');
942
+ }
943
+
944
+ private escapeString(str: string): string {
945
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
946
+ }
947
+ }
948
+
949
+ export const footprintConverter = new FootprintConverter();