@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,1682 @@
1
+ /**
2
+ * EasyEDA Symbol to KiCad Symbol Converter
3
+ * Converts EasyEDA symbol format to KiCad .kicad_sym format (KiCad 9 compatible)
4
+ */
5
+
6
+ import type {
7
+ EasyEDAComponentData,
8
+ EasyEDAPin,
9
+ EasyEDASymbolData,
10
+ EasyEDASymbolRect,
11
+ EasyEDASymbolCircle,
12
+ EasyEDASymbolEllipse,
13
+ EasyEDASymbolArc,
14
+ EasyEDASymbolPolyline,
15
+ EasyEDASymbolPolygon,
16
+ EasyEDASymbolPath,
17
+ } from '../types/index.js';
18
+ import { KICAD_SYMBOL_VERSION, KICAD_DEFAULTS } from '../constants/index.js';
19
+ import { roundTo } from '../utils/index.js';
20
+ import { extractDisplayValue } from './value-normalizer.js';
21
+ import { getSymbolTemplate, type SymbolTemplate } from './symbol-templates.js';
22
+ import { getLibraryCategory, type LibraryCategory } from './category-router.js';
23
+ import { parseSvgArcPath, svgArcToCenter, radToDeg, normalizeAngle } from './svg-arc.js';
24
+
25
+ // Map library categories to template prefixes for passive components
26
+ const CATEGORY_TO_PREFIX: Partial<Record<LibraryCategory, string>> = {
27
+ Resistors: 'R',
28
+ Capacitors: 'C',
29
+ Inductors: 'L',
30
+ Diodes: 'D',
31
+ };
32
+
33
+ // EasyEDA uses 10mil units (0.254mm per unit)
34
+ const EE_TO_MM = 0.254;
35
+
36
+ // IC symbol layout constants (matching CDFER/KiCad conventions)
37
+ const IC_PIN_LENGTH = 2.54; // mm (100 mil) - standard pin length
38
+ const IC_PIN_SPACING = 2.54; // mm (100 mil) - vertical spacing between pins
39
+ const IC_BODY_HALF_WIDTH = 12.7; // mm (500 mil) - half body width (full = 25.4mm)
40
+ const IC_PIN_FONT_SIZE = 1.0; // mm - smaller font for pin names/numbers
41
+ const IC_MIN_BODY_SIZE = 5.08; // mm (200 mil) - minimum body dimension
42
+ const IC_BODY_PADDING = 2.54; // mm (100 mil) - padding from pins to body edge
43
+
44
+ export interface SymbolConversionOptions {
45
+ libraryName?: string;
46
+ symbolName?: string;
47
+ includeDatasheet?: boolean;
48
+ includeManufacturer?: boolean;
49
+ }
50
+
51
+ interface BoundingBox {
52
+ minX: number;
53
+ maxX: number;
54
+ minY: number;
55
+ maxY: number;
56
+ }
57
+
58
+ /**
59
+ * DIP-style IC layout with calculated pin positions
60
+ */
61
+ interface ICLayout {
62
+ bodyWidth: number;
63
+ bodyHeight: number;
64
+ leftPins: Array<{ pin: EasyEDAPin; x: number; y: number }>;
65
+ rightPins: Array<{ pin: EasyEDAPin; x: number; y: number }>;
66
+ }
67
+
68
+ export class SymbolConverter {
69
+ /**
70
+ * Convert EasyEDA component data to KiCad symbol format string (standalone library)
71
+ */
72
+ convert(
73
+ component: EasyEDAComponentData,
74
+ options: SymbolConversionOptions = {}
75
+ ): string {
76
+ const symbolEntry = this.convertToSymbolEntry(component, options);
77
+ return this.generateHeader() + symbolEntry + ')\n';
78
+ }
79
+
80
+ /**
81
+ * Convert component to a symbol entry (without library wrapper)
82
+ * Used for appending to existing libraries
83
+ */
84
+ convertToSymbolEntry(
85
+ component: EasyEDAComponentData,
86
+ options: SymbolConversionOptions = {}
87
+ ): string {
88
+ const { info, symbol } = component;
89
+
90
+ // Try prefix-based template first
91
+ let template = getSymbolTemplate(info.prefix);
92
+
93
+ // If no template from prefix, check if category maps to a passive template
94
+ if (!template) {
95
+ const category = getLibraryCategory(info.prefix, info.category, info.description);
96
+ const categoryPrefix = CATEGORY_TO_PREFIX[category];
97
+ if (categoryPrefix) {
98
+ template = getSymbolTemplate(categoryPrefix);
99
+ }
100
+ }
101
+
102
+ if (template && symbol.pins.length === 2) {
103
+ // Use fixed layout for 2-pin passives only (R, C, L, D with 2 pins)
104
+ // Multi-pin components (bridge rectifiers, multi-terminal LEDs) use EasyEDA layout
105
+ return this.generateFromTemplate(component, template, options);
106
+ } else {
107
+ // Fall back to improved EasyEDA-derived layout for ICs
108
+ return this.generateFromEasyEDA(component, options);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Generate symbol from fixed template (for passives)
114
+ */
115
+ private generateFromTemplate(
116
+ component: EasyEDAComponentData,
117
+ template: SymbolTemplate,
118
+ options: SymbolConversionOptions = {}
119
+ ): string {
120
+ const { info, symbol } = component;
121
+ const name = options.symbolName ? this.sanitizeName(options.symbolName) : this.sanitizeName(info.name);
122
+ const pins = symbol.pins;
123
+
124
+ let output = this.generateSymbolStart(name);
125
+ output += this.generateTemplateProperties(info, name, template);
126
+ output += this.generateTemplateGraphics(name, template);
127
+ output += this.generateTemplatePins(name, pins, template);
128
+ output += this.generateSymbolEnd();
129
+
130
+ return output;
131
+ }
132
+
133
+ /**
134
+ * Generate symbol from EasyEDA data (for ICs and complex components)
135
+ * Uses parsed shapes if available, otherwise falls back to DIP-style layout
136
+ */
137
+ private generateFromEasyEDA(
138
+ component: EasyEDAComponentData,
139
+ options: SymbolConversionOptions = {}
140
+ ): string {
141
+ const { info, symbol } = component;
142
+ const name = options.symbolName ? this.sanitizeName(options.symbolName) : this.sanitizeName(info.name);
143
+
144
+ // Check if we have non-trivial shapes to render
145
+ if (this.hasRenderableShapes(symbol)) {
146
+ return this.generateFromShapes(component, name);
147
+ }
148
+
149
+ // Fall back to DIP-style layout
150
+ const layout = this.calculateICLayout(symbol.pins);
151
+
152
+ let output = this.generateSymbolStart(name, false); // Show pin numbers for ICs
153
+ output += this.generateProperties(info, name);
154
+ output += this.generateICGraphics(name, layout);
155
+ output += this.generateICPins(name, layout);
156
+ output += this.generateSymbolEnd();
157
+
158
+ return output;
159
+ }
160
+
161
+ /**
162
+ * Check if symbol has shapes worth rendering (not just pins)
163
+ */
164
+ private hasRenderableShapes(symbol: EasyEDASymbolData): boolean {
165
+ return (
166
+ symbol.rectangles.length > 0 ||
167
+ symbol.circles.length > 0 ||
168
+ symbol.ellipses.length > 0 ||
169
+ symbol.polylines.length > 0 ||
170
+ symbol.polygons.length > 0 ||
171
+ symbol.arcs.length > 0 ||
172
+ symbol.paths.length > 0
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Generate symbol using parsed EasyEDA shapes
178
+ */
179
+ private generateFromShapes(
180
+ component: EasyEDAComponentData,
181
+ name: string
182
+ ): string {
183
+ const { info, symbol } = component;
184
+ const origin = symbol.origin;
185
+
186
+ let output = this.generateSymbolStart(name, false);
187
+ output += this.generateProperties(info, name);
188
+
189
+ // Generate graphics unit with shapes
190
+ output += `\t\t(symbol "${name}_0_1"\n`;
191
+
192
+ // Convert each shape type
193
+ for (const rect of symbol.rectangles) {
194
+ output += this.convertRectangle(rect, origin);
195
+ }
196
+ for (const circle of symbol.circles) {
197
+ output += this.convertCircle(circle, origin);
198
+ }
199
+ for (const ellipse of symbol.ellipses) {
200
+ output += this.convertEllipse(ellipse, origin);
201
+ }
202
+ for (const polyline of symbol.polylines) {
203
+ output += this.convertPolyline(polyline, origin);
204
+ }
205
+ for (const polygon of symbol.polygons) {
206
+ output += this.convertPolygon(polygon, origin);
207
+ }
208
+ for (const arc of symbol.arcs) {
209
+ const arcOutput = this.convertArc(arc, origin);
210
+ if (arcOutput) output += arcOutput;
211
+ }
212
+ for (const path of symbol.paths) {
213
+ const pathOutput = this.convertPath(path, origin);
214
+ if (pathOutput) output += pathOutput;
215
+ }
216
+
217
+ output += `\t\t)\n`;
218
+
219
+ // Generate pins unit
220
+ output += `\t\t(symbol "${name}_1_1"\n`;
221
+ for (const pin of symbol.pins) {
222
+ output += this.generateShapePin(pin, origin);
223
+ }
224
+ output += `\t\t)\n`;
225
+
226
+ output += this.generateSymbolEnd();
227
+
228
+ return output;
229
+ }
230
+
231
+ /**
232
+ * Convert EasyEDA X coordinate to KiCad (with origin offset)
233
+ */
234
+ private convertX(x: number, originX: number): number {
235
+ return roundTo((x - originX) * EE_TO_MM, 3);
236
+ }
237
+
238
+ /**
239
+ * Convert EasyEDA Y coordinate to KiCad (Y-flipped with origin offset)
240
+ */
241
+ private convertY(y: number, originY: number): number {
242
+ return roundTo(-(y - originY) * EE_TO_MM, 3);
243
+ }
244
+
245
+ /**
246
+ * Convert stroke width from EasyEDA to KiCad
247
+ */
248
+ private convertStrokeWidth(width: number): number {
249
+ return roundTo(Math.max(width * EE_TO_MM, 0.1), 3);
250
+ }
251
+
252
+ /**
253
+ * Convert EasyEDA rectangle to KiCad format
254
+ */
255
+ private convertRectangle(rect: EasyEDASymbolRect, origin: { x: number; y: number }): string {
256
+ const x1 = this.convertX(rect.x, origin.x);
257
+ const y1 = this.convertY(rect.y, origin.y);
258
+ const x2 = this.convertX(rect.x + rect.width, origin.x);
259
+ const y2 = this.convertY(rect.y + rect.height, origin.y);
260
+ const strokeWidth = this.convertStrokeWidth(rect.strokeWidth);
261
+
262
+ return `\t\t\t(rectangle
263
+ \t\t\t\t(start ${x1} ${y1})
264
+ \t\t\t\t(end ${x2} ${y2})
265
+ \t\t\t\t(stroke
266
+ \t\t\t\t\t(width ${strokeWidth})
267
+ \t\t\t\t\t(type default)
268
+ \t\t\t\t)
269
+ \t\t\t\t(fill
270
+ \t\t\t\t\t(type background)
271
+ \t\t\t\t)
272
+ \t\t\t)
273
+ `;
274
+ }
275
+
276
+ /**
277
+ * Convert EasyEDA circle to KiCad format
278
+ */
279
+ private convertCircle(circle: EasyEDASymbolCircle, origin: { x: number; y: number }): string {
280
+ const cx = this.convertX(circle.cx, origin.x);
281
+ const cy = this.convertY(circle.cy, origin.y);
282
+ const radius = roundTo(circle.radius * EE_TO_MM, 3);
283
+ const strokeWidth = this.convertStrokeWidth(circle.strokeWidth);
284
+
285
+ return `\t\t\t(circle
286
+ \t\t\t\t(center ${cx} ${cy})
287
+ \t\t\t\t(radius ${radius})
288
+ \t\t\t\t(stroke
289
+ \t\t\t\t\t(width ${strokeWidth})
290
+ \t\t\t\t\t(type default)
291
+ \t\t\t\t)
292
+ \t\t\t\t(fill
293
+ \t\t\t\t\t(type none)
294
+ \t\t\t\t)
295
+ \t\t\t)
296
+ `;
297
+ }
298
+
299
+ /**
300
+ * Convert EasyEDA ellipse to KiCad format (approximated as circle if rx≈ry)
301
+ * KiCad symbols don't support true ellipses, so we use a circle with average radius
302
+ */
303
+ private convertEllipse(ellipse: EasyEDASymbolEllipse, origin: { x: number; y: number }): string {
304
+ const cx = this.convertX(ellipse.cx, origin.x);
305
+ const cy = this.convertY(ellipse.cy, origin.y);
306
+ // Use average radius for approximation
307
+ const radius = roundTo(((ellipse.radiusX + ellipse.radiusY) / 2) * EE_TO_MM, 3);
308
+ const strokeWidth = this.convertStrokeWidth(ellipse.strokeWidth);
309
+
310
+ return `\t\t\t(circle
311
+ \t\t\t\t(center ${cx} ${cy})
312
+ \t\t\t\t(radius ${radius})
313
+ \t\t\t\t(stroke
314
+ \t\t\t\t\t(width ${strokeWidth})
315
+ \t\t\t\t\t(type default)
316
+ \t\t\t\t)
317
+ \t\t\t\t(fill
318
+ \t\t\t\t\t(type none)
319
+ \t\t\t\t)
320
+ \t\t\t)
321
+ `;
322
+ }
323
+
324
+ /**
325
+ * Convert EasyEDA polyline to KiCad format
326
+ */
327
+ private convertPolyline(polyline: EasyEDASymbolPolyline, origin: { x: number; y: number }): string {
328
+ const points = this.parsePoints(polyline.points, origin);
329
+ if (points.length < 2) return '';
330
+
331
+ const strokeWidth = this.convertStrokeWidth(polyline.strokeWidth);
332
+ const pointsStr = points.map(p => `(xy ${p.x} ${p.y})`).join('\n\t\t\t\t\t');
333
+
334
+ return `\t\t\t(polyline
335
+ \t\t\t\t(pts
336
+ \t\t\t\t\t${pointsStr}
337
+ \t\t\t\t)
338
+ \t\t\t\t(stroke
339
+ \t\t\t\t\t(width ${strokeWidth})
340
+ \t\t\t\t\t(type default)
341
+ \t\t\t\t)
342
+ \t\t\t\t(fill
343
+ \t\t\t\t\t(type none)
344
+ \t\t\t\t)
345
+ \t\t\t)
346
+ `;
347
+ }
348
+
349
+ /**
350
+ * Convert EasyEDA polygon to KiCad format (closed, filled polyline)
351
+ */
352
+ private convertPolygon(polygon: EasyEDASymbolPolygon, origin: { x: number; y: number }): string {
353
+ const points = this.parsePoints(polygon.points, origin);
354
+ if (points.length < 3) return '';
355
+
356
+ const strokeWidth = this.convertStrokeWidth(polygon.strokeWidth);
357
+ const pointsStr = points.map(p => `(xy ${p.x} ${p.y})`).join('\n\t\t\t\t\t');
358
+
359
+ return `\t\t\t(polyline
360
+ \t\t\t\t(pts
361
+ \t\t\t\t\t${pointsStr}
362
+ \t\t\t\t)
363
+ \t\t\t\t(stroke
364
+ \t\t\t\t\t(width ${strokeWidth})
365
+ \t\t\t\t\t(type default)
366
+ \t\t\t\t)
367
+ \t\t\t\t(fill
368
+ \t\t\t\t\t(type background)
369
+ \t\t\t\t)
370
+ \t\t\t)
371
+ `;
372
+ }
373
+
374
+ /**
375
+ * Convert EasyEDA arc to KiCad format
376
+ * Uses SVG arc to center parameterization conversion
377
+ */
378
+ private convertArc(arc: EasyEDASymbolArc, origin: { x: number; y: number }): string | null {
379
+ // Parse SVG arc path
380
+ const params = parseSvgArcPath(arc.path);
381
+ if (!params) return null;
382
+
383
+ // Convert to center parameterization
384
+ const center = svgArcToCenter(params);
385
+ if (!center) return null;
386
+
387
+ // Convert coordinates from EasyEDA to KiCad
388
+ const cx = this.convertX(center.cx, origin.x);
389
+ const cy = this.convertY(center.cy, origin.y);
390
+ const radius = roundTo(center.rx * EE_TO_MM, 3); // Use rx (arcs are typically circular)
391
+
392
+ // Convert start and end points
393
+ const startX = this.convertX(params.x1, origin.x);
394
+ const startY = this.convertY(params.y1, origin.y);
395
+ const endX = this.convertX(params.x2, origin.x);
396
+ const endY = this.convertY(params.y2, origin.y);
397
+
398
+ // Calculate mid-point on arc for KiCad
399
+ const midAngle = center.startAngle + center.deltaAngle / 2;
400
+ const midX = roundTo(cx + radius * Math.cos(midAngle), 3);
401
+ const midY = roundTo(cy - radius * Math.sin(midAngle), 3); // Y inverted
402
+
403
+ const strokeWidth = this.convertStrokeWidth(arc.strokeWidth);
404
+
405
+ return `\t\t\t(arc
406
+ \t\t\t\t(start ${startX} ${startY})
407
+ \t\t\t\t(mid ${midX} ${midY})
408
+ \t\t\t\t(end ${endX} ${endY})
409
+ \t\t\t\t(stroke
410
+ \t\t\t\t\t(width ${strokeWidth})
411
+ \t\t\t\t\t(type default)
412
+ \t\t\t\t)
413
+ \t\t\t\t(fill
414
+ \t\t\t\t\t(type none)
415
+ \t\t\t\t)
416
+ \t\t\t)
417
+ `;
418
+ }
419
+
420
+ /**
421
+ * Convert EasyEDA SVG path to KiCad polyline
422
+ * Supports M (move), L (line), Z (close) commands
423
+ */
424
+ private convertPath(path: EasyEDASymbolPath, origin: { x: number; y: number }): string | null {
425
+ const points = this.parseSvgPath(path.path, origin);
426
+ if (points.length < 2) return null;
427
+
428
+ const strokeWidth = this.convertStrokeWidth(path.strokeWidth);
429
+ const hasFill = path.fillColor && path.fillColor !== 'none' && path.fillColor !== '';
430
+
431
+ let output = `\t\t\t(polyline\n`;
432
+ output += `\t\t\t\t(pts\n`;
433
+
434
+ for (const pt of points) {
435
+ output += `\t\t\t\t\t(xy ${pt.x} ${pt.y})\n`;
436
+ }
437
+
438
+ output += `\t\t\t\t)\n`;
439
+ output += `\t\t\t\t(stroke\n`;
440
+ output += `\t\t\t\t\t(width ${strokeWidth})\n`;
441
+ output += `\t\t\t\t\t(type default)\n`;
442
+ output += `\t\t\t\t)\n`;
443
+ output += `\t\t\t\t(fill\n`;
444
+ output += `\t\t\t\t\t(type ${hasFill ? 'outline' : 'none'})\n`;
445
+ output += `\t\t\t\t)\n`;
446
+ output += `\t\t\t)\n`;
447
+
448
+ return output;
449
+ }
450
+
451
+ /**
452
+ * Parse SVG path string (M/L/Z commands) to array of points
453
+ * Format: "M x1,y1 L x2,y2 L x3,y3 Z" or "M x1 y1 L x2 y2 ..."
454
+ */
455
+ private parseSvgPath(pathStr: string, origin: { x: number; y: number }): Array<{ x: number; y: number }> {
456
+ const points: Array<{ x: number; y: number }> = [];
457
+ let firstPoint: { x: number; y: number } | null = null;
458
+
459
+ // Match M and L commands with coordinates (supports both comma and space separators)
460
+ const commandRegex = /([MLZ])\s*([\d.-]+)?[,\s]*([\d.-]+)?/gi;
461
+ let match;
462
+
463
+ while ((match = commandRegex.exec(pathStr)) !== null) {
464
+ const cmd = match[1].toUpperCase();
465
+
466
+ if (cmd === 'M' || cmd === 'L') {
467
+ const x = parseFloat(match[2]);
468
+ const y = parseFloat(match[3]);
469
+
470
+ if (!isNaN(x) && !isNaN(y)) {
471
+ const point = {
472
+ x: this.convertX(x, origin.x),
473
+ y: this.convertY(y, origin.y),
474
+ };
475
+ points.push(point);
476
+
477
+ if (cmd === 'M' && !firstPoint) {
478
+ firstPoint = point;
479
+ }
480
+ }
481
+ } else if (cmd === 'Z' && firstPoint) {
482
+ // Close path - add first point if not already there
483
+ const lastPoint = points[points.length - 1];
484
+ if (lastPoint && (lastPoint.x !== firstPoint.x || lastPoint.y !== firstPoint.y)) {
485
+ points.push({ ...firstPoint });
486
+ }
487
+ }
488
+ }
489
+
490
+ return points;
491
+ }
492
+
493
+ /**
494
+ * Parse space-separated point string to array of {x, y}
495
+ */
496
+ private parsePoints(pointsStr: string, origin: { x: number; y: number }): Array<{ x: number; y: number }> {
497
+ const values = pointsStr.trim().split(/\s+/).map(parseFloat);
498
+ const points: Array<{ x: number; y: number }> = [];
499
+
500
+ for (let i = 0; i < values.length - 1; i += 2) {
501
+ points.push({
502
+ x: this.convertX(values[i], origin.x),
503
+ y: this.convertY(values[i + 1], origin.y),
504
+ });
505
+ }
506
+
507
+ return points;
508
+ }
509
+
510
+ /**
511
+ * Generate a pin using EasyEDA coordinates and rotation
512
+ *
513
+ * EasyEDA: pin.x/y is the WIRE connection point (junction)
514
+ * rotation points FROM wire TO body
515
+ * KiCad: pin position is the WIRE connection point
516
+ * rotation points FROM wire TO body
517
+ *
518
+ * So we just need coordinate conversion with Y-flip!
519
+ */
520
+ private generateShapePin(pin: EasyEDAPin, origin: { x: number; y: number }): string {
521
+ const pinType = this.mapPinType(pin.electricalType);
522
+
523
+ // Convert wire/junction position to KiCad coordinates (with Y-flip)
524
+ const x = this.convertX(pin.x, origin.x);
525
+ const y = this.convertY(pin.y, origin.y);
526
+
527
+ // Map EasyEDA rotation to KiCad rotation
528
+ // Both point FROM wire TO body, but Y-axis is flipped
529
+ const rotation = this.mapPinRotation(pin.rotation);
530
+
531
+ // Convert pin length
532
+ const pinLength = roundTo(pin.pinLength * EE_TO_MM, 2);
533
+
534
+ // Determine pin style based on indicators
535
+ const pinStyle = this.getPinStyle(pin);
536
+
537
+ const ts = IC_PIN_FONT_SIZE;
538
+
539
+ return `\t\t\t(pin ${pinType} ${pinStyle}
540
+ \t\t\t\t(at ${x} ${y} ${rotation})
541
+ \t\t\t\t(length ${pinLength})
542
+ \t\t\t\t(name "${this.sanitizePinName(pin.name)}"
543
+ \t\t\t\t\t(effects
544
+ \t\t\t\t\t\t(font
545
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
546
+ \t\t\t\t\t\t)
547
+ \t\t\t\t\t)
548
+ \t\t\t\t)
549
+ \t\t\t\t(number "${pin.number}"
550
+ \t\t\t\t\t(effects
551
+ \t\t\t\t\t\t(font
552
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
553
+ \t\t\t\t\t\t)
554
+ \t\t\t\t\t)
555
+ \t\t\t\t)
556
+ \t\t\t)
557
+ `;
558
+ }
559
+
560
+ /**
561
+ * Map EasyEDA pin rotation to KiCad rotation
562
+ *
563
+ * EasyEDA: rotation indicates direction pin EXTENDS from wire (away from body)
564
+ * KiCad: rotation indicates direction pin POINTS (from wire toward body)
565
+ *
566
+ * So we need to flip by 180°, then account for Y-axis flip:
567
+ * - EasyEDA 0 (extends right, body on left) -> KiCad 180 (points left toward body)
568
+ * - EasyEDA 180 (extends left, body on right) -> KiCad 0 (points right toward body)
569
+ * - EasyEDA 90 (extends down, body on top) -> KiCad 90 (points up toward body, Y-flipped)
570
+ * - EasyEDA 270 (extends up, body on bottom) -> KiCad 270 (points down toward body, Y-flipped)
571
+ */
572
+ private mapPinRotation(eeRotation: number): number {
573
+ const normalized = ((eeRotation % 360) + 360) % 360;
574
+ // Flip 180° to point toward body instead of away
575
+ // Then swap vertical due to Y-flip (but 180° flip already handles this naturally)
576
+ return (normalized + 180) % 360;
577
+ }
578
+
579
+ /**
580
+ * Get KiCad pin style from EasyEDA pin indicators
581
+ */
582
+ private getPinStyle(pin: EasyEDAPin): string {
583
+ if (pin.hasDot && pin.hasClock) return 'inverted_clock';
584
+ if (pin.hasDot) return 'inverted';
585
+ if (pin.hasClock) return 'clock';
586
+ return 'line';
587
+ }
588
+
589
+ /**
590
+ * Calculate DIP-style IC layout from EasyEDA pins
591
+ * Matches CDFER layout: wide body, pins extending from edges
592
+ */
593
+ private calculateICLayout(pins: EasyEDAPin[]): ICLayout {
594
+ const pinCount = pins.length;
595
+ const halfPins = Math.ceil(pinCount / 2);
596
+
597
+ // Sort pins by pin number for proper DIP arrangement
598
+ const sortedPins = [...pins].sort((a, b) => {
599
+ const numA = parseInt(a.number) || 0;
600
+ const numB = parseInt(b.number) || 0;
601
+ return numA - numB;
602
+ });
603
+
604
+ // Calculate body dimensions (matching CDFER style)
605
+ // Height based on pin count: top pin at (halfPins-1)*spacing, bottom at 0
606
+ // Add padding above top pin and below bottom pin
607
+ const topY = (halfPins - 1) * IC_PIN_SPACING;
608
+ const bodyHeight = topY + IC_PIN_SPACING * 2; // Add padding top and bottom
609
+ const bodyWidth = IC_BODY_HALF_WIDTH * 2;
610
+ const halfHeight = bodyHeight / 2;
611
+
612
+ // Pin X positions (body edge + pin length)
613
+ const leftX = roundTo(-(IC_BODY_HALF_WIDTH + IC_PIN_LENGTH), 2);
614
+ const rightX = roundTo(IC_BODY_HALF_WIDTH + IC_PIN_LENGTH, 2);
615
+
616
+ // Split pins: 1 to n/2 on left (top to bottom), rest on right (bottom to top)
617
+ const leftPins: ICLayout['leftPins'] = [];
618
+ const rightPins: ICLayout['rightPins'] = [];
619
+
620
+ for (let i = 0; i < sortedPins.length; i++) {
621
+ const pin = sortedPins[i];
622
+ if (i < halfPins) {
623
+ // Left side pins (top to bottom): pin 1 at top
624
+ const y = topY - (i * IC_PIN_SPACING);
625
+ leftPins.push({ pin, x: leftX, y: roundTo(y, 3) });
626
+ } else {
627
+ // Right side pins (bottom to top): continues from bottom
628
+ const rightIndex = i - halfPins;
629
+ const y = rightIndex * IC_PIN_SPACING;
630
+ rightPins.push({ pin, x: rightX, y: roundTo(y, 3) });
631
+ }
632
+ }
633
+
634
+ return { bodyWidth, bodyHeight, leftPins, rightPins };
635
+ }
636
+
637
+ /**
638
+ * Generate IC body rectangle and pins in single _0_1 unit (CDFER style)
639
+ */
640
+ private generateICGraphics(name: string, layout: ICLayout): string {
641
+ // Body: extends from bottom pin - padding to top pin + padding
642
+ const topPinY = layout.leftPins[0]?.y ?? 0;
643
+ const bottomPinY = layout.leftPins[layout.leftPins.length - 1]?.y ?? 0;
644
+ const bodyTop = topPinY + IC_PIN_SPACING;
645
+ const bodyBottom = bottomPinY - IC_PIN_SPACING;
646
+
647
+ let output = `\t\t(symbol "${name}_0_1"
648
+ \t\t\t(rectangle
649
+ \t\t\t\t(start ${-IC_BODY_HALF_WIDTH} ${roundTo(bodyTop, 2)})
650
+ \t\t\t\t(end ${IC_BODY_HALF_WIDTH} ${roundTo(bodyBottom, 2)})
651
+ \t\t\t\t(stroke
652
+ \t\t\t\t\t(width 0)
653
+ \t\t\t\t\t(type default)
654
+ \t\t\t\t)
655
+ \t\t\t\t(fill
656
+ \t\t\t\t\t(type background)
657
+ \t\t\t\t)
658
+ \t\t\t)
659
+ `;
660
+
661
+ // Add all pins to _0_1 unit (CDFER style - no separate _1_1 unit)
662
+ // Left side pins (pointing right, rotation 0)
663
+ for (const { pin, x, y } of layout.leftPins) {
664
+ const pinType = this.mapPinType(pin.electricalType);
665
+ output += `\t\t\t(pin ${pinType} line
666
+ \t\t\t\t(at ${x} ${y} 0)
667
+ \t\t\t\t(length ${IC_PIN_LENGTH})
668
+ \t\t\t\t(name "${this.sanitizePinName(pin.name)}"
669
+ \t\t\t\t\t(effects
670
+ \t\t\t\t\t\t(font
671
+ \t\t\t\t\t\t\t(size ${IC_PIN_FONT_SIZE} ${IC_PIN_FONT_SIZE})
672
+ \t\t\t\t\t\t)
673
+ \t\t\t\t\t)
674
+ \t\t\t\t)
675
+ \t\t\t\t(number "${pin.number}"
676
+ \t\t\t\t\t(effects
677
+ \t\t\t\t\t\t(font
678
+ \t\t\t\t\t\t\t(size ${IC_PIN_FONT_SIZE} ${IC_PIN_FONT_SIZE})
679
+ \t\t\t\t\t\t)
680
+ \t\t\t\t\t)
681
+ \t\t\t\t)
682
+ \t\t\t)
683
+ `;
684
+ }
685
+
686
+ // Right side pins (pointing left, rotation 180)
687
+ for (const { pin, x, y } of layout.rightPins) {
688
+ const pinType = this.mapPinType(pin.electricalType);
689
+ output += `\t\t\t(pin ${pinType} line
690
+ \t\t\t\t(at ${x} ${y} 180)
691
+ \t\t\t\t(length ${IC_PIN_LENGTH})
692
+ \t\t\t\t(name "${this.sanitizePinName(pin.name)}"
693
+ \t\t\t\t\t(effects
694
+ \t\t\t\t\t\t(font
695
+ \t\t\t\t\t\t\t(size ${IC_PIN_FONT_SIZE} ${IC_PIN_FONT_SIZE})
696
+ \t\t\t\t\t\t)
697
+ \t\t\t\t\t)
698
+ \t\t\t\t)
699
+ \t\t\t\t(number "${pin.number}"
700
+ \t\t\t\t\t(effects
701
+ \t\t\t\t\t\t(font
702
+ \t\t\t\t\t\t\t(size ${IC_PIN_FONT_SIZE} ${IC_PIN_FONT_SIZE})
703
+ \t\t\t\t\t\t)
704
+ \t\t\t\t\t)
705
+ \t\t\t\t)
706
+ \t\t\t)
707
+ `;
708
+ }
709
+
710
+ output += `\t\t)
711
+ `;
712
+ return output;
713
+ }
714
+
715
+ /**
716
+ * Generate empty IC pins unit (pins are in _0_1 for CDFER compatibility)
717
+ */
718
+ private generateICPins(name: string, layout: ICLayout): string {
719
+ // No _1_1 unit needed - all pins in _0_1
720
+ return '';
721
+ }
722
+
723
+ /**
724
+ * Create a new library file with multiple symbols
725
+ */
726
+ createLibrary(components: EasyEDAComponentData[]): string {
727
+ let output = this.generateHeader();
728
+ for (const component of components) {
729
+ output += this.convertToSymbolEntry(component);
730
+ }
731
+ output += ')\n';
732
+ return output;
733
+ }
734
+
735
+ /**
736
+ * Append a symbol to an existing library file content
737
+ * Returns the updated library content
738
+ */
739
+ appendToLibrary(
740
+ existingLibraryContent: string,
741
+ component: EasyEDAComponentData,
742
+ options: SymbolConversionOptions = {}
743
+ ): string {
744
+ // Remove the closing parenthesis and any trailing whitespace
745
+ const trimmed = existingLibraryContent.trimEnd();
746
+ if (!trimmed.endsWith(')')) {
747
+ throw new Error('Invalid library file format: missing closing parenthesis');
748
+ }
749
+
750
+ // Remove the last closing paren
751
+ const withoutClose = trimmed.slice(0, -1);
752
+
753
+ // Add the new symbol entry and close the library
754
+ const newSymbol = this.convertToSymbolEntry(component, options);
755
+ return withoutClose + newSymbol + ')\n';
756
+ }
757
+
758
+ /**
759
+ * Check if a symbol already exists in a library
760
+ */
761
+ symbolExistsInLibrary(libraryContent: string, componentName: string): boolean {
762
+ const sanitizedName = this.sanitizeName(componentName);
763
+ // Look for (symbol "NAME" pattern
764
+ const pattern = new RegExp(`\\(symbol\\s+"${sanitizedName}"`, 'm');
765
+ return pattern.test(libraryContent);
766
+ }
767
+
768
+ /**
769
+ * Replace an existing symbol in a library with a new version
770
+ * If the symbol doesn't exist, it will be appended
771
+ */
772
+ replaceInLibrary(
773
+ existingLibraryContent: string,
774
+ component: EasyEDAComponentData,
775
+ options: SymbolConversionOptions = {}
776
+ ): string {
777
+ const sanitizedName = this.sanitizeName(component.info.name);
778
+
779
+ // Check if symbol exists
780
+ if (!this.symbolExistsInLibrary(existingLibraryContent, component.info.name)) {
781
+ // Symbol doesn't exist, just append it
782
+ return this.appendToLibrary(existingLibraryContent, component, options);
783
+ }
784
+
785
+ // Remove the existing symbol
786
+ // Symbol format: (symbol "NAME" ... ) with nested parens
787
+ // We need to find the start and match balanced parens to find the end
788
+
789
+ const symbolStart = existingLibraryContent.indexOf(`(symbol "${sanitizedName}"`);
790
+ if (symbolStart === -1) {
791
+ // Shouldn't happen since we checked above, but fallback to append
792
+ return this.appendToLibrary(existingLibraryContent, component, options);
793
+ }
794
+
795
+ // Find the matching closing paren by counting balance
796
+ let depth = 0;
797
+ let symbolEnd = symbolStart;
798
+ let inString = false;
799
+ let prevChar = '';
800
+
801
+ for (let i = symbolStart; i < existingLibraryContent.length; i++) {
802
+ const char = existingLibraryContent[i];
803
+
804
+ // Handle string escaping
805
+ if (char === '"' && prevChar !== '\\') {
806
+ inString = !inString;
807
+ }
808
+
809
+ if (!inString) {
810
+ if (char === '(') depth++;
811
+ if (char === ')') {
812
+ depth--;
813
+ if (depth === 0) {
814
+ symbolEnd = i + 1;
815
+ break;
816
+ }
817
+ }
818
+ }
819
+
820
+ prevChar = char;
821
+ }
822
+
823
+ // Remove the old symbol (including any trailing newline)
824
+ let contentWithoutOldSymbol = existingLibraryContent.slice(0, symbolStart);
825
+ let afterSymbol = existingLibraryContent.slice(symbolEnd);
826
+
827
+ // Trim leading newlines from afterSymbol to avoid double spacing
828
+ while (afterSymbol.startsWith('\n')) {
829
+ afterSymbol = afterSymbol.slice(1);
830
+ }
831
+
832
+ contentWithoutOldSymbol = contentWithoutOldSymbol + afterSymbol;
833
+
834
+ // Now append the new version
835
+ return this.appendToLibrary(contentWithoutOldSymbol, component, options);
836
+ }
837
+
838
+ /**
839
+ * Get the sanitized symbol name for a component
840
+ */
841
+ getSymbolName(component: EasyEDAComponentData): string {
842
+ return this.sanitizeName(component.info.name);
843
+ }
844
+
845
+ /**
846
+ * Generate file header
847
+ */
848
+ private generateHeader(): string {
849
+ return `(kicad_symbol_lib
850
+ \t(version ${KICAD_SYMBOL_VERSION})
851
+ \t(generator "ai-eda-jlc-mcp")
852
+ \t(generator_version "9.0")
853
+ `;
854
+ }
855
+
856
+ /**
857
+ * Generate symbol start - NO library prefix in symbol name
858
+ */
859
+ private generateSymbolStart(name: string, hideNumbers = true): string {
860
+ // For passives, hide pin numbers; for ICs, show them (CDFER style)
861
+ if (hideNumbers) {
862
+ return `\t(symbol "${name}"
863
+ \t\t(pin_numbers
864
+ \t\t\t(hide yes)
865
+ \t\t)
866
+ \t\t(pin_names
867
+ \t\t\t(offset 0)
868
+ \t\t)
869
+ \t\t(exclude_from_sim no)
870
+ \t\t(in_bom yes)
871
+ \t\t(on_board yes)
872
+ `;
873
+ }
874
+ // IC style - no pin_numbers/pin_names sections (show by default)
875
+ return `\t(symbol "${name}"
876
+ \t\t(exclude_from_sim no)
877
+ \t\t(in_bom yes)
878
+ \t\t(on_board yes)
879
+ `;
880
+ }
881
+
882
+ /**
883
+ * Generate KiCad property entries in KiCad 9 format
884
+ */
885
+ private generateProperties(info: EasyEDAComponentData['info'], name: string): string {
886
+ const ts = KICAD_DEFAULTS.TEXT_SIZE;
887
+ let props = '';
888
+
889
+ // Extract normalized display value for passives
890
+ const displayValue = extractDisplayValue(
891
+ info.name,
892
+ info.description,
893
+ info.prefix,
894
+ info.category
895
+ );
896
+
897
+ // Reference property - centered above body (CDFER style, no "?" suffix)
898
+ const refDesignator = (info.prefix || 'U').replace(/\?$/, '');
899
+ props += `\t\t(property "Reference" "${refDesignator}"
900
+ \t\t\t(at 0 10.16 0)
901
+ \t\t\t(effects
902
+ \t\t\t\t(font
903
+ \t\t\t\t\t(size ${ts} ${ts})
904
+ \t\t\t\t)
905
+ \t\t\t)
906
+ \t\t)
907
+ `;
908
+
909
+ // Value property - centered below reference (CDFER style)
910
+ props += `\t\t(property "Value" "${this.sanitizeText(displayValue)}"
911
+ \t\t\t(at 0 7.62 0)
912
+ \t\t\t(effects
913
+ \t\t\t\t(font
914
+ \t\t\t\t\t(size ${ts} ${ts})
915
+ \t\t\t\t)
916
+ \t\t\t)
917
+ \t\t)
918
+ `;
919
+
920
+ // Footprint property
921
+ props += `\t\t(property "Footprint" "${info.package || ''}"
922
+ \t\t\t(at -1.778 0 90)
923
+ \t\t\t(effects
924
+ \t\t\t\t(font
925
+ \t\t\t\t\t(size ${ts} ${ts})
926
+ \t\t\t\t)
927
+ \t\t\t\t(hide yes)
928
+ \t\t\t)
929
+ \t\t)
930
+ `;
931
+
932
+ // Datasheet property - prefer JLC API PDF URL, fallback to constructed URL
933
+ const datasheetUrl = info.datasheetPdf || (info.lcscId ? `https://www.lcsc.com/datasheet/${info.lcscId}.pdf` : '~');
934
+ props += `\t\t(property "Datasheet" "${datasheetUrl}"
935
+ \t\t\t(at 0 0 0)
936
+ \t\t\t(effects
937
+ \t\t\t\t(font
938
+ \t\t\t\t\t(size ${ts} ${ts})
939
+ \t\t\t\t)
940
+ \t\t\t\t(hide yes)
941
+ \t\t\t)
942
+ \t\t)
943
+ `;
944
+
945
+ // Product Page property - link to LCSC product page
946
+ if (info.datasheet) {
947
+ props += `\t\t(property "Product Page" "${info.datasheet}"
948
+ \t\t\t(at 0 0 0)
949
+ \t\t\t(effects
950
+ \t\t\t\t(font
951
+ \t\t\t\t\t(size ${ts} ${ts})
952
+ \t\t\t\t)
953
+ \t\t\t\t(hide yes)
954
+ \t\t\t)
955
+ \t\t)
956
+ `;
957
+ }
958
+
959
+ // Description property - use actual description if available
960
+ const description = info.description || info.name;
961
+ props += `\t\t(property "Description" "${this.sanitizeText(description)}"
962
+ \t\t\t(at 0 0 0)
963
+ \t\t\t(effects
964
+ \t\t\t\t(font
965
+ \t\t\t\t\t(size ${ts} ${ts})
966
+ \t\t\t\t)
967
+ \t\t\t\t(hide yes)
968
+ \t\t\t)
969
+ \t\t)
970
+ `;
971
+
972
+ // LCSC property
973
+ if (info.lcscId) {
974
+ props += `\t\t(property "LCSC" "${info.lcscId}"
975
+ \t\t\t(at 0 0 0)
976
+ \t\t\t(effects
977
+ \t\t\t\t(font
978
+ \t\t\t\t\t(size ${ts} ${ts})
979
+ \t\t\t\t)
980
+ \t\t\t\t(hide yes)
981
+ \t\t\t)
982
+ \t\t)
983
+ `;
984
+ }
985
+
986
+ // Manufacturer property
987
+ if (info.manufacturer) {
988
+ props += `\t\t(property "Manufacturer" "${this.sanitizeText(info.manufacturer)}"
989
+ \t\t\t(at 0 0 0)
990
+ \t\t\t(effects
991
+ \t\t\t\t(font
992
+ \t\t\t\t\t(size ${ts} ${ts})
993
+ \t\t\t\t)
994
+ \t\t\t\t(hide yes)
995
+ \t\t\t)
996
+ \t\t)
997
+ `;
998
+ }
999
+
1000
+ // Category property
1001
+ if (info.category) {
1002
+ props += `\t\t(property "Category" "${this.sanitizeText(info.category)}"
1003
+ \t\t\t(at 0 0 0)
1004
+ \t\t\t(effects
1005
+ \t\t\t\t(font
1006
+ \t\t\t\t\t(size ${ts} ${ts})
1007
+ \t\t\t\t)
1008
+ \t\t\t\t(hide yes)
1009
+ \t\t\t)
1010
+ \t\t)
1011
+ `;
1012
+ }
1013
+
1014
+ // ki_keywords property - LCSC ID for searchability (CDFER style)
1015
+ if (info.lcscId) {
1016
+ props += `\t\t(property "ki_keywords" "${info.lcscId}"
1017
+ \t\t\t(at 0 0 0)
1018
+ \t\t\t(effects
1019
+ \t\t\t\t(font
1020
+ \t\t\t\t\t(size ${ts} ${ts})
1021
+ \t\t\t\t)
1022
+ \t\t\t\t(hide yes)
1023
+ \t\t\t)
1024
+ \t\t)
1025
+ `;
1026
+ }
1027
+
1028
+ // CDFER parity properties
1029
+ if (info.stock !== undefined) {
1030
+ props += `\t\t(property "Stock" "${info.stock}"
1031
+ \t\t\t(at 0 0 0)
1032
+ \t\t\t(effects
1033
+ \t\t\t\t(font
1034
+ \t\t\t\t\t(size ${ts} ${ts})
1035
+ \t\t\t\t)
1036
+ \t\t\t\t(hide yes)
1037
+ \t\t\t)
1038
+ \t\t)
1039
+ `;
1040
+ }
1041
+
1042
+ if (info.price !== undefined) {
1043
+ props += `\t\t(property "Price" "${info.price}USD"
1044
+ \t\t\t(at 0 0 0)
1045
+ \t\t\t(effects
1046
+ \t\t\t\t(font
1047
+ \t\t\t\t\t(size ${ts} ${ts})
1048
+ \t\t\t\t)
1049
+ \t\t\t\t(hide yes)
1050
+ \t\t\t)
1051
+ \t\t)
1052
+ `;
1053
+ }
1054
+
1055
+ if (info.process) {
1056
+ props += `\t\t(property "Process" "${info.process}"
1057
+ \t\t\t(at 0 0 0)
1058
+ \t\t\t(effects
1059
+ \t\t\t\t(font
1060
+ \t\t\t\t\t(size ${ts} ${ts})
1061
+ \t\t\t\t)
1062
+ \t\t\t\t(hide yes)
1063
+ \t\t\t)
1064
+ \t\t)
1065
+ `;
1066
+ }
1067
+
1068
+ if (info.minOrderQty !== undefined) {
1069
+ props += `\t\t(property "Minimum Qty" "${info.minOrderQty}"
1070
+ \t\t\t(at 0 0 0)
1071
+ \t\t\t(effects
1072
+ \t\t\t\t(font
1073
+ \t\t\t\t\t(size ${ts} ${ts})
1074
+ \t\t\t\t)
1075
+ \t\t\t\t(hide yes)
1076
+ \t\t\t)
1077
+ \t\t)
1078
+ `;
1079
+ }
1080
+
1081
+ // Attrition Qty - always add with default 0
1082
+ props += `\t\t(property "Attrition Qty" "0"
1083
+ \t\t\t(at 0 0 0)
1084
+ \t\t\t(effects
1085
+ \t\t\t\t(font
1086
+ \t\t\t\t\t(size ${ts} ${ts})
1087
+ \t\t\t\t)
1088
+ \t\t\t\t(hide yes)
1089
+ \t\t\t)
1090
+ \t\t)
1091
+ `;
1092
+
1093
+ if (info.partClass) {
1094
+ props += `\t\t(property "Class" "${this.sanitizeText(info.partClass)}"
1095
+ \t\t\t(at 0 0 0)
1096
+ \t\t\t(effects
1097
+ \t\t\t\t(font
1098
+ \t\t\t\t\t(size ${ts} ${ts})
1099
+ \t\t\t\t)
1100
+ \t\t\t\t(hide yes)
1101
+ \t\t\t)
1102
+ \t\t)
1103
+ `;
1104
+ }
1105
+
1106
+ if (info.partNumber) {
1107
+ props += `\t\t(property "Part" "${this.sanitizeText(info.partNumber)}"
1108
+ \t\t\t(at 0 0 0)
1109
+ \t\t\t(effects
1110
+ \t\t\t\t(font
1111
+ \t\t\t\t\t(size ${ts} ${ts})
1112
+ \t\t\t\t)
1113
+ \t\t\t\t(hide yes)
1114
+ \t\t\t)
1115
+ \t\t)
1116
+ `;
1117
+ }
1118
+
1119
+ // Component attributes as custom properties
1120
+ if (info.attributes) {
1121
+ for (const [key, value] of Object.entries(info.attributes)) {
1122
+ props += `\t\t(property "${this.sanitizeText(key)}" "${this.sanitizeText(String(value))}"
1123
+ \t\t\t(at 0 0 0)
1124
+ \t\t\t(effects
1125
+ \t\t\t\t(font
1126
+ \t\t\t\t\t(size ${ts} ${ts})
1127
+ \t\t\t\t)
1128
+ \t\t\t\t(hide yes)
1129
+ \t\t\t)
1130
+ \t\t)
1131
+ `;
1132
+ }
1133
+ }
1134
+
1135
+ return props;
1136
+ }
1137
+
1138
+ /**
1139
+ * Calculate bounding box from pins (for IC/complex components)
1140
+ * Uses improved padding for better visual appearance
1141
+ */
1142
+ private calculateBoundingBox(pins: EasyEDAPin[], origin: { x: number; y: number }): BoundingBox {
1143
+ let minX = Infinity, maxX = -Infinity;
1144
+ let minY = Infinity, maxY = -Infinity;
1145
+
1146
+ for (const pin of pins) {
1147
+ const x = (pin.x - origin.x) * EE_TO_MM;
1148
+ const y = -(pin.y - origin.y) * EE_TO_MM;
1149
+ minX = Math.min(minX, x);
1150
+ maxX = Math.max(maxX, x);
1151
+ minY = Math.min(minY, y);
1152
+ maxY = Math.max(maxY, y);
1153
+ }
1154
+
1155
+ // If no pins, use minimum IC body size
1156
+ if (!isFinite(minX)) {
1157
+ const half = IC_MIN_BODY_SIZE / 2;
1158
+ return { minX: -half, maxX: half, minY: -half, maxY: half };
1159
+ }
1160
+
1161
+ // Add padding and shrink to create body rectangle inside pin positions
1162
+ // Uses IC_BODY_PADDING for adequate spacing between pins and body edge
1163
+ return {
1164
+ minX: roundTo(minX + IC_BODY_PADDING, 3),
1165
+ maxX: roundTo(maxX - IC_BODY_PADDING, 3),
1166
+ minY: roundTo(minY + IC_BODY_PADDING, 3),
1167
+ maxY: roundTo(maxY - IC_BODY_PADDING, 3),
1168
+ };
1169
+ }
1170
+
1171
+ /**
1172
+ * Generate _0_1 unit with graphical elements (rectangle body)
1173
+ * Uses IC_MIN_BODY_SIZE for minimum dimensions
1174
+ */
1175
+ private generateGraphicsUnit(name: string, bbox: BoundingBox): string {
1176
+ // Ensure minimum size for ICs
1177
+ const width = Math.max(bbox.maxX - bbox.minX, IC_MIN_BODY_SIZE);
1178
+ const height = Math.max(bbox.maxY - bbox.minY, IC_MIN_BODY_SIZE);
1179
+
1180
+ // Center the rectangle if too small
1181
+ let x1 = bbox.minX;
1182
+ let x2 = bbox.maxX;
1183
+ let y1 = bbox.minY;
1184
+ let y2 = bbox.maxY;
1185
+
1186
+ const halfMin = IC_MIN_BODY_SIZE / 2;
1187
+ if (width < IC_MIN_BODY_SIZE) {
1188
+ x1 = -halfMin;
1189
+ x2 = halfMin;
1190
+ }
1191
+ if (height < IC_MIN_BODY_SIZE) {
1192
+ y1 = -halfMin;
1193
+ y2 = halfMin;
1194
+ }
1195
+
1196
+ return `\t\t(symbol "${name}_0_1"
1197
+ \t\t\t(rectangle
1198
+ \t\t\t\t(start ${roundTo(x1, 3)} ${roundTo(y1, 3)})
1199
+ \t\t\t\t(end ${roundTo(x2, 3)} ${roundTo(y2, 3)})
1200
+ \t\t\t\t(stroke
1201
+ \t\t\t\t\t(width 0.254)
1202
+ \t\t\t\t\t(type default)
1203
+ \t\t\t\t)
1204
+ \t\t\t\t(fill
1205
+ \t\t\t\t\t(type background)
1206
+ \t\t\t\t)
1207
+ \t\t\t)
1208
+ \t\t)
1209
+ `;
1210
+ }
1211
+
1212
+ /**
1213
+ * Generate _1_1 unit with pins
1214
+ */
1215
+ private generatePinsUnit(name: string, pins: EasyEDAPin[], origin: { x: number; y: number }): string {
1216
+ let output = `\t\t(symbol "${name}_1_1"
1217
+ `;
1218
+
1219
+ for (const pin of pins) {
1220
+ output += this.generatePin(pin, origin);
1221
+ }
1222
+
1223
+ output += `\t\t)
1224
+ `;
1225
+ return output;
1226
+ }
1227
+
1228
+ /**
1229
+ * Generate a single pin entry in KiCad 9 format
1230
+ * Uses IC_PIN_LENGTH for better readability with complex components
1231
+ */
1232
+ private generatePin(pin: EasyEDAPin, origin: { x: number; y: number }): string {
1233
+ const pinType = this.mapPinType(pin.electricalType);
1234
+ const x = roundTo((pin.x - origin.x) * EE_TO_MM, 3);
1235
+ const y = roundTo(-(pin.y - origin.y) * EE_TO_MM, 3);
1236
+ const rotation = this.calculatePinRotation(x, y);
1237
+ const ts = KICAD_DEFAULTS.TEXT_SIZE;
1238
+
1239
+ return `\t\t\t(pin ${pinType} line
1240
+ \t\t\t\t(at ${x} ${y} ${rotation})
1241
+ \t\t\t\t(length ${IC_PIN_LENGTH})
1242
+ \t\t\t\t(name "${this.sanitizePinName(pin.name)}"
1243
+ \t\t\t\t\t(effects
1244
+ \t\t\t\t\t\t(font
1245
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
1246
+ \t\t\t\t\t\t)
1247
+ \t\t\t\t\t)
1248
+ \t\t\t\t)
1249
+ \t\t\t\t(number "${pin.number}"
1250
+ \t\t\t\t\t(effects
1251
+ \t\t\t\t\t\t(font
1252
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
1253
+ \t\t\t\t\t\t)
1254
+ \t\t\t\t\t)
1255
+ \t\t\t\t)
1256
+ \t\t\t)
1257
+ `;
1258
+ }
1259
+
1260
+ /**
1261
+ * Generate symbol end (closes the symbol, NOT the library)
1262
+ */
1263
+ private generateSymbolEnd(): string {
1264
+ return `\t\t(embedded_fonts no)
1265
+ \t)
1266
+ `;
1267
+ }
1268
+
1269
+ /**
1270
+ * Generate properties for template-based symbols
1271
+ */
1272
+ private generateTemplateProperties(
1273
+ info: EasyEDAComponentData['info'],
1274
+ name: string,
1275
+ template: SymbolTemplate
1276
+ ): string {
1277
+ const ts = KICAD_DEFAULTS.TEXT_SIZE;
1278
+ let props = '';
1279
+
1280
+ // Extract normalized display value for passives
1281
+ const displayValue = extractDisplayValue(
1282
+ info.name,
1283
+ info.description,
1284
+ info.prefix,
1285
+ info.category
1286
+ );
1287
+
1288
+ // Reference property - positioned from template (no "?" suffix)
1289
+ const refDesignator = (info.prefix || 'U').replace(/\?$/, '');
1290
+ props += `\t\t(property "Reference" "${refDesignator}"
1291
+ \t\t\t(at ${template.refPosition.x} ${template.refPosition.y} ${template.refPosition.angle})
1292
+ \t\t\t(effects
1293
+ \t\t\t\t(font
1294
+ \t\t\t\t\t(size ${ts} ${ts})
1295
+ \t\t\t\t)
1296
+ \t\t\t)
1297
+ \t\t)
1298
+ `;
1299
+
1300
+ // Value property - positioned from template
1301
+ props += `\t\t(property "Value" "${this.sanitizeText(displayValue)}"
1302
+ \t\t\t(at ${template.valuePosition.x} ${template.valuePosition.y} ${template.valuePosition.angle})
1303
+ \t\t\t(effects
1304
+ \t\t\t\t(font
1305
+ \t\t\t\t\t(size ${ts} ${ts})
1306
+ \t\t\t\t)
1307
+ \t\t\t)
1308
+ \t\t)
1309
+ `;
1310
+
1311
+ // Footprint property
1312
+ props += `\t\t(property "Footprint" "${info.package || ''}"
1313
+ \t\t\t(at 0 0 0)
1314
+ \t\t\t(effects
1315
+ \t\t\t\t(font
1316
+ \t\t\t\t\t(size ${ts} ${ts})
1317
+ \t\t\t\t)
1318
+ \t\t\t\t(hide yes)
1319
+ \t\t\t)
1320
+ \t\t)
1321
+ `;
1322
+
1323
+ // Datasheet property - prefer JLC API PDF URL, fallback to constructed URL
1324
+ const datasheetUrl = info.datasheetPdf || (info.lcscId ? `https://www.lcsc.com/datasheet/${info.lcscId}.pdf` : '~');
1325
+ props += `\t\t(property "Datasheet" "${datasheetUrl}"
1326
+ \t\t\t(at 0 0 0)
1327
+ \t\t\t(effects
1328
+ \t\t\t\t(font
1329
+ \t\t\t\t\t(size ${ts} ${ts})
1330
+ \t\t\t\t)
1331
+ \t\t\t\t(hide yes)
1332
+ \t\t\t)
1333
+ \t\t)
1334
+ `;
1335
+
1336
+ // Product Page property - link to LCSC product page
1337
+ if (info.datasheet) {
1338
+ props += `\t\t(property "Product Page" "${info.datasheet}"
1339
+ \t\t\t(at 0 0 0)
1340
+ \t\t\t(effects
1341
+ \t\t\t\t(font
1342
+ \t\t\t\t\t(size ${ts} ${ts})
1343
+ \t\t\t\t)
1344
+ \t\t\t\t(hide yes)
1345
+ \t\t\t)
1346
+ \t\t)
1347
+ `;
1348
+ }
1349
+
1350
+ // Description property
1351
+ const description = info.description || info.name;
1352
+ props += `\t\t(property "Description" "${this.sanitizeText(description)}"
1353
+ \t\t\t(at 0 0 0)
1354
+ \t\t\t(effects
1355
+ \t\t\t\t(font
1356
+ \t\t\t\t\t(size ${ts} ${ts})
1357
+ \t\t\t\t)
1358
+ \t\t\t\t(hide yes)
1359
+ \t\t\t)
1360
+ \t\t)
1361
+ `;
1362
+
1363
+ // LCSC property
1364
+ if (info.lcscId) {
1365
+ props += `\t\t(property "LCSC" "${info.lcscId}"
1366
+ \t\t\t(at 0 0 0)
1367
+ \t\t\t(effects
1368
+ \t\t\t\t(font
1369
+ \t\t\t\t\t(size ${ts} ${ts})
1370
+ \t\t\t\t)
1371
+ \t\t\t\t(hide yes)
1372
+ \t\t\t)
1373
+ \t\t)
1374
+ `;
1375
+ }
1376
+
1377
+ // Manufacturer property
1378
+ if (info.manufacturer) {
1379
+ props += `\t\t(property "Manufacturer" "${this.sanitizeText(info.manufacturer)}"
1380
+ \t\t\t(at 0 0 0)
1381
+ \t\t\t(effects
1382
+ \t\t\t\t(font
1383
+ \t\t\t\t\t(size ${ts} ${ts})
1384
+ \t\t\t\t)
1385
+ \t\t\t\t(hide yes)
1386
+ \t\t\t)
1387
+ \t\t)
1388
+ `;
1389
+ }
1390
+
1391
+ // Category property
1392
+ if (info.category) {
1393
+ props += `\t\t(property "Category" "${this.sanitizeText(info.category)}"
1394
+ \t\t\t(at 0 0 0)
1395
+ \t\t\t(effects
1396
+ \t\t\t\t(font
1397
+ \t\t\t\t\t(size ${ts} ${ts})
1398
+ \t\t\t\t)
1399
+ \t\t\t\t(hide yes)
1400
+ \t\t\t)
1401
+ \t\t)
1402
+ `;
1403
+ }
1404
+
1405
+ // ki_keywords property - LCSC ID for searchability (CDFER style)
1406
+ if (info.lcscId) {
1407
+ props += `\t\t(property "ki_keywords" "${info.lcscId}"
1408
+ \t\t\t(at 0 0 0)
1409
+ \t\t\t(effects
1410
+ \t\t\t\t(font
1411
+ \t\t\t\t\t(size ${ts} ${ts})
1412
+ \t\t\t\t)
1413
+ \t\t\t\t(hide yes)
1414
+ \t\t\t)
1415
+ \t\t)
1416
+ `;
1417
+ }
1418
+
1419
+ // CDFER parity properties
1420
+ if (info.stock !== undefined) {
1421
+ props += `\t\t(property "Stock" "${info.stock}"
1422
+ \t\t\t(at 0 0 0)
1423
+ \t\t\t(effects
1424
+ \t\t\t\t(font
1425
+ \t\t\t\t\t(size ${ts} ${ts})
1426
+ \t\t\t\t)
1427
+ \t\t\t\t(hide yes)
1428
+ \t\t\t)
1429
+ \t\t)
1430
+ `;
1431
+ }
1432
+
1433
+ if (info.price !== undefined) {
1434
+ props += `\t\t(property "Price" "${info.price}USD"
1435
+ \t\t\t(at 0 0 0)
1436
+ \t\t\t(effects
1437
+ \t\t\t\t(font
1438
+ \t\t\t\t\t(size ${ts} ${ts})
1439
+ \t\t\t\t)
1440
+ \t\t\t\t(hide yes)
1441
+ \t\t\t)
1442
+ \t\t)
1443
+ `;
1444
+ }
1445
+
1446
+ if (info.process) {
1447
+ props += `\t\t(property "Process" "${info.process}"
1448
+ \t\t\t(at 0 0 0)
1449
+ \t\t\t(effects
1450
+ \t\t\t\t(font
1451
+ \t\t\t\t\t(size ${ts} ${ts})
1452
+ \t\t\t\t)
1453
+ \t\t\t\t(hide yes)
1454
+ \t\t\t)
1455
+ \t\t)
1456
+ `;
1457
+ }
1458
+
1459
+ if (info.minOrderQty !== undefined) {
1460
+ props += `\t\t(property "Minimum Qty" "${info.minOrderQty}"
1461
+ \t\t\t(at 0 0 0)
1462
+ \t\t\t(effects
1463
+ \t\t\t\t(font
1464
+ \t\t\t\t\t(size ${ts} ${ts})
1465
+ \t\t\t\t)
1466
+ \t\t\t\t(hide yes)
1467
+ \t\t\t)
1468
+ \t\t)
1469
+ `;
1470
+ }
1471
+
1472
+ // Attrition Qty - always add with default 0
1473
+ props += `\t\t(property "Attrition Qty" "0"
1474
+ \t\t\t(at 0 0 0)
1475
+ \t\t\t(effects
1476
+ \t\t\t\t(font
1477
+ \t\t\t\t\t(size ${ts} ${ts})
1478
+ \t\t\t\t)
1479
+ \t\t\t\t(hide yes)
1480
+ \t\t\t)
1481
+ \t\t)
1482
+ `;
1483
+
1484
+ if (info.partClass) {
1485
+ props += `\t\t(property "Class" "${this.sanitizeText(info.partClass)}"
1486
+ \t\t\t(at 0 0 0)
1487
+ \t\t\t(effects
1488
+ \t\t\t\t(font
1489
+ \t\t\t\t\t(size ${ts} ${ts})
1490
+ \t\t\t\t)
1491
+ \t\t\t\t(hide yes)
1492
+ \t\t\t)
1493
+ \t\t)
1494
+ `;
1495
+ }
1496
+
1497
+ if (info.partNumber) {
1498
+ props += `\t\t(property "Part" "${this.sanitizeText(info.partNumber)}"
1499
+ \t\t\t(at 0 0 0)
1500
+ \t\t\t(effects
1501
+ \t\t\t\t(font
1502
+ \t\t\t\t\t(size ${ts} ${ts})
1503
+ \t\t\t\t)
1504
+ \t\t\t\t(hide yes)
1505
+ \t\t\t)
1506
+ \t\t)
1507
+ `;
1508
+ }
1509
+
1510
+ // Component attributes as custom properties
1511
+ if (info.attributes) {
1512
+ for (const [key, value] of Object.entries(info.attributes)) {
1513
+ props += `\t\t(property "${this.sanitizeText(key)}" "${this.sanitizeText(String(value))}"
1514
+ \t\t\t(at 0 0 0)
1515
+ \t\t\t(effects
1516
+ \t\t\t\t(font
1517
+ \t\t\t\t\t(size ${ts} ${ts})
1518
+ \t\t\t\t)
1519
+ \t\t\t\t(hide yes)
1520
+ \t\t\t)
1521
+ \t\t)
1522
+ `;
1523
+ }
1524
+ }
1525
+
1526
+ return props;
1527
+ }
1528
+
1529
+ /**
1530
+ * Generate graphics unit for template-based symbols
1531
+ */
1532
+ private generateTemplateGraphics(name: string, template: SymbolTemplate): string {
1533
+ return `\t\t(symbol "${name}_0_1"
1534
+ ${template.bodyGraphics}
1535
+ \t\t)
1536
+ `;
1537
+ }
1538
+
1539
+ /**
1540
+ * Generate pins for template-based symbols (2-pin vertical layout)
1541
+ */
1542
+ private generateTemplatePins(
1543
+ name: string,
1544
+ pins: EasyEDAPin[],
1545
+ template: SymbolTemplate
1546
+ ): string {
1547
+ const ts = KICAD_DEFAULTS.TEXT_SIZE;
1548
+ const halfSpacing = template.pinSpacing / 2;
1549
+
1550
+ let output = `\t\t(symbol "${name}_1_1"
1551
+ `;
1552
+
1553
+ // For 2-pin components, place at fixed positions
1554
+ if (pins.length === 2) {
1555
+ // Pin 1 at top (270° points down toward body)
1556
+ const pin1 = pins[0];
1557
+ output += `\t\t\t(pin passive line
1558
+ \t\t\t\t(at 0 ${halfSpacing} 270)
1559
+ \t\t\t\t(length ${template.pinLength})
1560
+ \t\t\t\t(name "${this.sanitizePinName(pin1.name)}"
1561
+ \t\t\t\t\t(effects
1562
+ \t\t\t\t\t\t(font
1563
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
1564
+ \t\t\t\t\t\t)
1565
+ \t\t\t\t\t)
1566
+ \t\t\t\t)
1567
+ \t\t\t\t(number "${pin1.number}"
1568
+ \t\t\t\t\t(effects
1569
+ \t\t\t\t\t\t(font
1570
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
1571
+ \t\t\t\t\t\t)
1572
+ \t\t\t\t\t)
1573
+ \t\t\t\t)
1574
+ \t\t\t)
1575
+ `;
1576
+
1577
+ // Pin 2 at bottom (90° points up toward body)
1578
+ const pin2 = pins[1];
1579
+ output += `\t\t\t(pin passive line
1580
+ \t\t\t\t(at 0 ${-halfSpacing} 90)
1581
+ \t\t\t\t(length ${template.pinLength})
1582
+ \t\t\t\t(name "${this.sanitizePinName(pin2.name)}"
1583
+ \t\t\t\t\t(effects
1584
+ \t\t\t\t\t\t(font
1585
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
1586
+ \t\t\t\t\t\t)
1587
+ \t\t\t\t\t)
1588
+ \t\t\t\t)
1589
+ \t\t\t\t(number "${pin2.number}"
1590
+ \t\t\t\t\t(effects
1591
+ \t\t\t\t\t\t(font
1592
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
1593
+ \t\t\t\t\t\t)
1594
+ \t\t\t\t\t)
1595
+ \t\t\t\t)
1596
+ \t\t\t)
1597
+ `;
1598
+ } else {
1599
+ // For multi-pin components (e.g., LED with 3 pins), fall back to EasyEDA positions
1600
+ for (const pin of pins) {
1601
+ const pinType = this.mapPinType(pin.electricalType);
1602
+ output += `\t\t\t(pin ${pinType} line
1603
+ \t\t\t\t(at 0 0 0)
1604
+ \t\t\t\t(length ${template.pinLength})
1605
+ \t\t\t\t(name "${this.sanitizePinName(pin.name)}"
1606
+ \t\t\t\t\t(effects
1607
+ \t\t\t\t\t\t(font
1608
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
1609
+ \t\t\t\t\t\t)
1610
+ \t\t\t\t\t)
1611
+ \t\t\t\t)
1612
+ \t\t\t\t(number "${pin.number}"
1613
+ \t\t\t\t\t(effects
1614
+ \t\t\t\t\t\t(font
1615
+ \t\t\t\t\t\t\t(size ${ts} ${ts})
1616
+ \t\t\t\t\t\t)
1617
+ \t\t\t\t\t)
1618
+ \t\t\t\t)
1619
+ \t\t\t)
1620
+ `;
1621
+ }
1622
+ }
1623
+
1624
+ output += `\t\t)
1625
+ `;
1626
+ return output;
1627
+ }
1628
+
1629
+ /**
1630
+ * Map EasyEDA pin type to KiCad pin type
1631
+ */
1632
+ private mapPinType(eeType: string): string {
1633
+ const mapping: Record<string, string> = {
1634
+ '0': 'unspecified',
1635
+ '1': 'input',
1636
+ '2': 'output',
1637
+ '3': 'bidirectional',
1638
+ '4': 'power_in',
1639
+ '5': 'power_out',
1640
+ '6': 'open_collector',
1641
+ '7': 'open_emitter',
1642
+ '8': 'passive',
1643
+ '9': 'no_connect',
1644
+ };
1645
+ return mapping[eeType] || 'passive';
1646
+ }
1647
+
1648
+ /**
1649
+ * Calculate pin rotation based on position (points inward toward center)
1650
+ */
1651
+ private calculatePinRotation(x: number, y: number): number {
1652
+ if (Math.abs(x) > Math.abs(y)) {
1653
+ return x > 0 ? 180 : 0;
1654
+ } else {
1655
+ return y > 0 ? 270 : 90;
1656
+ }
1657
+ }
1658
+
1659
+ /**
1660
+ * Sanitize component name for KiCad (no special chars except underscore/hyphen)
1661
+ */
1662
+ private sanitizeName(name: string): string {
1663
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_');
1664
+ }
1665
+
1666
+ /**
1667
+ * Sanitize pin name (escape quotes)
1668
+ */
1669
+ private sanitizePinName(name: string): string {
1670
+ if (!name || name.trim() === '') return '~';
1671
+ return name.replace(/"/g, "'").replace(/\\/g, '');
1672
+ }
1673
+
1674
+ /**
1675
+ * Sanitize general text for properties
1676
+ */
1677
+ private sanitizeText(text: string): string {
1678
+ return text.replace(/"/g, "'").replace(/\\/g, '').replace(/\n/g, ' ');
1679
+ }
1680
+ }
1681
+
1682
+ export const symbolConverter = new SymbolConverter();