@vessel-dsp/core 0.5.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 (99) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +6 -0
  3. package/package.json +56 -0
  4. package/src/editor/commands.ts +344 -0
  5. package/src/editor/factory.ts +148 -0
  6. package/src/editor/history.ts +142 -0
  7. package/src/editor/index.ts +11 -0
  8. package/src/editor/layout.ts +207 -0
  9. package/src/formats/circuit-json/serializer.ts +1410 -0
  10. package/src/formats/document.ts +274 -0
  11. package/src/formats/interchange/parser.ts +1165 -0
  12. package/src/formats/interchange/serializer.ts +594 -0
  13. package/src/formats/ltspice/catalog.ts +181 -0
  14. package/src/formats/ltspice/encoding.ts +151 -0
  15. package/src/formats/ltspice/parser.ts +432 -0
  16. package/src/formats/ltspice/serializer.ts +169 -0
  17. package/src/formats/schx/catalog.ts +439 -0
  18. package/src/formats/schx/parser.ts +261 -0
  19. package/src/formats/schx/runtime-descriptors.ts +502 -0
  20. package/src/formats/schx/serializer.ts +211 -0
  21. package/src/formats/schx/transforms.ts +38 -0
  22. package/src/formats/spice/parser.ts +373 -0
  23. package/src/formats/spice/serializer.ts +43 -0
  24. package/src/index.ts +205 -0
  25. package/src/model/connectivity.ts +239 -0
  26. package/src/model/netlist.ts +375 -0
  27. package/src/model/properties.ts +101 -0
  28. package/src/model/quantity.ts +173 -0
  29. package/src/model/types.ts +309 -0
  30. package/src/model/validation.ts +985 -0
  31. package/src/model/wires.ts +86 -0
  32. package/src/panel/extract.ts +878 -0
  33. package/src/panel/index.ts +39 -0
  34. package/src/panel/knobs.ts +70 -0
  35. package/src/panel/protocol.ts +117 -0
  36. package/src/panel/types.ts +180 -0
  37. package/src/preview/bounds.ts +85 -0
  38. package/src/preview/box-layout.ts +24 -0
  39. package/src/preview/colors.ts +43 -0
  40. package/src/preview/hanging.ts +94 -0
  41. package/src/preview/junctions.ts +94 -0
  42. package/src/preview/label-layout.ts +90 -0
  43. package/src/preview/ports.ts +101 -0
  44. package/src/preview/renderable-wires.ts +113 -0
  45. package/src/preview/routing.ts +15 -0
  46. package/src/preview/snap.ts +104 -0
  47. package/src/preview/symbols/analog-switch.svg +17 -0
  48. package/src/preview/symbols/battery.svg +16 -0
  49. package/src/preview/symbols/bbd.svg +21 -0
  50. package/src/preview/symbols/bjt-npn.svg +16 -0
  51. package/src/preview/symbols/bjt-pnp.svg +17 -0
  52. package/src/preview/symbols/capacitor-electrolytic.svg +13 -0
  53. package/src/preview/symbols/capacitor.svg +12 -0
  54. package/src/preview/symbols/current-source.svg +14 -0
  55. package/src/preview/symbols/delay-ic.svg +22 -0
  56. package/src/preview/symbols/diode-schottky.svg +12 -0
  57. package/src/preview/symbols/diode-zener.svg +12 -0
  58. package/src/preview/symbols/diode.svg +13 -0
  59. package/src/preview/symbols/flipflop.svg +20 -0
  60. package/src/preview/symbols/ground.svg +12 -0
  61. package/src/preview/symbols/ic-block.svg +20 -0
  62. package/src/preview/symbols/ic.svg +19 -0
  63. package/src/preview/symbols/inductor.svg +11 -0
  64. package/src/preview/symbols/jack-input.svg +16 -0
  65. package/src/preview/symbols/jack-output.svg +16 -0
  66. package/src/preview/symbols/jfet-junction-n.svg +17 -0
  67. package/src/preview/symbols/jfet-n.svg +17 -0
  68. package/src/preview/symbols/jfet-p.svg +17 -0
  69. package/src/preview/symbols/label.svg +8 -0
  70. package/src/preview/symbols/led.svg +18 -0
  71. package/src/preview/symbols/mosfet-n.svg +21 -0
  72. package/src/preview/symbols/mosfet-p.svg +21 -0
  73. package/src/preview/symbols/named-wire.svg +11 -0
  74. package/src/preview/symbols/opamp.svg +21 -0
  75. package/src/preview/symbols/optocoupler.svg +30 -0
  76. package/src/preview/symbols/ota.svg +20 -0
  77. package/src/preview/symbols/pentode.svg +25 -0
  78. package/src/preview/symbols/photoresistor.svg +19 -0
  79. package/src/preview/symbols/port.svg +8 -0
  80. package/src/preview/symbols/potentiometer.svg +15 -0
  81. package/src/preview/symbols/power-amp.svg +20 -0
  82. package/src/preview/symbols/rail.svg +11 -0
  83. package/src/preview/symbols/regulator.svg +13 -0
  84. package/src/preview/symbols/relay.svg +20 -0
  85. package/src/preview/symbols/resistor.svg +11 -0
  86. package/src/preview/symbols/svg-content.ts +59 -0
  87. package/src/preview/symbols/switch-3pdt.svg +32 -0
  88. package/src/preview/symbols/switch-rotary.svg +23 -0
  89. package/src/preview/symbols/switch-spdt.svg +16 -0
  90. package/src/preview/symbols/switch-spst.svg +14 -0
  91. package/src/preview/symbols/switch-toggle.svg +14 -0
  92. package/src/preview/symbols/transformer.svg +17 -0
  93. package/src/preview/symbols/triode.svg +17 -0
  94. package/src/preview/symbols/tube-diode.svg +13 -0
  95. package/src/preview/symbols/unsupported.svg +8 -0
  96. package/src/preview/symbols/variable-resistor.svg +13 -0
  97. package/src/preview/symbols/voltage-source.svg +15 -0
  98. package/src/preview/symbols.ts +207 -0
  99. package/src/preview/wire-chains.ts +153 -0
@@ -0,0 +1,432 @@
1
+ import { parseQuantity } from '../../model/quantity';
2
+ import type {
3
+ CircuitDocument,
4
+ Component,
5
+ DocumentMetadata,
6
+ Point,
7
+ PropertyValue,
8
+ Warning,
9
+ Wire,
10
+ } from '../../model/types';
11
+ import { splitWiresAtJunctions } from '../../model/wires';
12
+ import {
13
+ extractModelFromSymbolPath,
14
+ lookupLtspiceSymbolDef,
15
+ LTSPICE_COORD_SCALE,
16
+ mapLtspiceTerminal,
17
+ normalizeLtspiceSymbolName,
18
+ type LtspiceSymbolDef,
19
+ } from './catalog';
20
+ import { decodeLtspiceBytes } from './encoding';
21
+
22
+ type MutableLtspiceSymbol = {
23
+ readonly sourceName: string;
24
+ readonly placement: Point;
25
+ readonly orientation: string;
26
+ readonly index: number;
27
+ readonly attrs: Map<string, string>;
28
+ };
29
+
30
+ type LtspiceFlag = Readonly<{
31
+ point: Point;
32
+ name: string;
33
+ }>;
34
+
35
+ type LtspiceText = Readonly<{
36
+ point: Point;
37
+ text: string;
38
+ }>;
39
+
40
+ const IGNORED_KEYWORDS = new Set<string>([
41
+ 'WINDOW',
42
+ 'LINE',
43
+ 'RECTANGLE',
44
+ 'CIRCLE',
45
+ 'ARC',
46
+ 'DATAFLAG',
47
+ 'BUSTAP',
48
+ ]);
49
+
50
+ export function parseLtspiceAsc(source: string | Uint8Array): CircuitDocument {
51
+ const text = typeof source === 'string' ? source : decodeLtspiceBytes(source);
52
+ const normalized = text.replace(/^/, '').replace(/\r\n/g, '\n');
53
+ const lines = normalized.split('\n');
54
+ const firstLine = lines.find((line) => line.trim().length > 0)?.trim() ?? '';
55
+ if (!firstLine.toUpperCase().startsWith('VERSION ')) {
56
+ throw new Error('not an LTspice ASC schematic: missing Version header');
57
+ }
58
+
59
+ const warnings: Warning[] = [];
60
+ const wires: Wire[] = [];
61
+ const symbols: MutableLtspiceSymbol[] = [];
62
+ const flags: LtspiceFlag[] = [];
63
+ const iopins = new Map<string, string>();
64
+ const directives: string[] = [];
65
+ const texts: LtspiceText[] = [];
66
+ let currentSymbol: MutableLtspiceSymbol | null = null;
67
+ let version = '';
68
+ let sheet = '';
69
+
70
+ lines.forEach((line, lineIndex) => {
71
+ const trimmed = line.trim();
72
+ if (trimmed.length === 0) {
73
+ return;
74
+ }
75
+
76
+ const tokens = trimmed.split(/\s+/);
77
+ const keyword = (tokens[0] ?? '').toUpperCase();
78
+
79
+ switch (keyword) {
80
+ case 'VERSION':
81
+ version = tokens.slice(1).join(' ');
82
+ break;
83
+ case 'SHEET':
84
+ sheet = tokens.slice(1).join(' ');
85
+ break;
86
+ case 'WIRE': {
87
+ const wire = parseWire(tokens, wires.length, lineIndex, warnings);
88
+ if (wire !== null) {
89
+ wires.push(wire);
90
+ }
91
+ break;
92
+ }
93
+ case 'FLAG': {
94
+ const flag = parseFlag(tokens, lineIndex, warnings);
95
+ if (flag !== null) {
96
+ flags.push(flag);
97
+ }
98
+ break;
99
+ }
100
+ case 'IOPIN': {
101
+ const iopin = parseIoPin(tokens, lineIndex, warnings);
102
+ if (iopin !== null) {
103
+ iopins.set(pointKey(iopin.point), iopin.polarity);
104
+ }
105
+ break;
106
+ }
107
+ case 'SYMBOL': {
108
+ currentSymbol = parseSymbol(tokens, symbols.length, lineIndex, warnings);
109
+ if (currentSymbol !== null) {
110
+ symbols.push(currentSymbol);
111
+ }
112
+ break;
113
+ }
114
+ case 'SYMATTR': {
115
+ parseSymbolAttribute(tokens, currentSymbol, lineIndex, warnings);
116
+ break;
117
+ }
118
+ case 'TEXT': {
119
+ parseText(tokens, lineIndex, warnings, directives, texts);
120
+ break;
121
+ }
122
+ default:
123
+ if (!IGNORED_KEYWORDS.has(keyword)) {
124
+ warnings.push({
125
+ code: 'unknown-ltspice-line',
126
+ message: `Line ${lineIndex + 1}: unsupported LTspice command "${tokens[0] ?? ''}"`,
127
+ });
128
+ }
129
+ break;
130
+ }
131
+ });
132
+
133
+ const usedIds = new Map<string, number>();
134
+ const components = [
135
+ ...symbols.map((symbol) => buildSymbolComponent(symbol, usedIds, warnings)),
136
+ ...flags.map((flag) => buildFlagComponent(flag, iopins.get(pointKey(flag.point)), usedIds)),
137
+ ...texts.map((text, index) => buildTextComponent(text, index, usedIds)),
138
+ ];
139
+ const metadata: DocumentMetadata = { name: '', description: '', partNumber: '' };
140
+
141
+ return {
142
+ metadata,
143
+ components,
144
+ wires: splitWiresAtJunctions(wires),
145
+ directives,
146
+ warnings,
147
+ rawAttributes: buildRawAttributes(version, sheet),
148
+ };
149
+ }
150
+
151
+ function parseWire(
152
+ tokens: readonly string[],
153
+ wireCount: number,
154
+ lineIndex: number,
155
+ warnings: Warning[],
156
+ ): Wire | null {
157
+ if (tokens.length < 5) {
158
+ warnings.push({ code: 'invalid-wire', message: `Line ${lineIndex + 1}: WIRE requires four coordinates` });
159
+ return null;
160
+ }
161
+ const a = parsePoint(tokens[1], tokens[2]);
162
+ const b = parsePoint(tokens[3], tokens[4]);
163
+ if (a === null || b === null) {
164
+ warnings.push({ code: 'invalid-wire', message: `Line ${lineIndex + 1}: WIRE has malformed coordinates` });
165
+ return null;
166
+ }
167
+ return { id: `wire-${wireCount + 1}`, endpoints: [a, b] };
168
+ }
169
+
170
+ function parseFlag(tokens: readonly string[], lineIndex: number, warnings: Warning[]): LtspiceFlag | null {
171
+ if (tokens.length < 4) {
172
+ warnings.push({ code: 'invalid-flag', message: `Line ${lineIndex + 1}: FLAG requires x, y, and name` });
173
+ return null;
174
+ }
175
+ const point = parsePoint(tokens[1], tokens[2]);
176
+ if (point === null) {
177
+ warnings.push({ code: 'invalid-flag', message: `Line ${lineIndex + 1}: FLAG has malformed coordinates` });
178
+ return null;
179
+ }
180
+ return { point, name: tokens.slice(3).join(' ') };
181
+ }
182
+
183
+ function parseIoPin(
184
+ tokens: readonly string[],
185
+ lineIndex: number,
186
+ warnings: Warning[],
187
+ ): Readonly<{ point: Point; polarity: string }> | null {
188
+ if (tokens.length < 4) {
189
+ warnings.push({ code: 'invalid-iopin', message: `Line ${lineIndex + 1}: IOPIN requires x, y, and polarity` });
190
+ return null;
191
+ }
192
+ const point = parsePoint(tokens[1], tokens[2]);
193
+ if (point === null) {
194
+ warnings.push({ code: 'invalid-iopin', message: `Line ${lineIndex + 1}: IOPIN has malformed coordinates` });
195
+ return null;
196
+ }
197
+ return { point, polarity: tokens.slice(3).join(' ') };
198
+ }
199
+
200
+ function parseSymbol(
201
+ tokens: readonly string[],
202
+ symbolCount: number,
203
+ lineIndex: number,
204
+ warnings: Warning[],
205
+ ): MutableLtspiceSymbol | null {
206
+ if (tokens.length < 5) {
207
+ warnings.push({ code: 'invalid-symbol', message: `Line ${lineIndex + 1}: SYMBOL requires name, x, y, and orientation` });
208
+ return null;
209
+ }
210
+ const placement = parsePoint(tokens[2], tokens[3]);
211
+ if (placement === null) {
212
+ warnings.push({ code: 'invalid-symbol', message: `Line ${lineIndex + 1}: SYMBOL has malformed coordinates` });
213
+ return null;
214
+ }
215
+ return {
216
+ sourceName: tokens[1] ?? '',
217
+ placement,
218
+ orientation: tokens[4] ?? 'R0',
219
+ index: symbolCount,
220
+ attrs: new Map<string, string>(),
221
+ };
222
+ }
223
+
224
+ function parseSymbolAttribute(
225
+ tokens: readonly string[],
226
+ currentSymbol: MutableLtspiceSymbol | null,
227
+ lineIndex: number,
228
+ warnings: Warning[],
229
+ ): void {
230
+ if (currentSymbol === null) {
231
+ warnings.push({ code: 'orphan-symbol-attribute', message: `Line ${lineIndex + 1}: SYMATTR without preceding SYMBOL` });
232
+ return;
233
+ }
234
+ if (tokens.length < 3) {
235
+ warnings.push({ code: 'invalid-symbol-attribute', message: `Line ${lineIndex + 1}: SYMATTR requires key and value` });
236
+ return;
237
+ }
238
+ currentSymbol.attrs.set(tokens[1] ?? '', tokens.slice(2).join(' '));
239
+ }
240
+
241
+ function parseText(
242
+ tokens: readonly string[],
243
+ lineIndex: number,
244
+ warnings: Warning[],
245
+ directives: string[],
246
+ texts: LtspiceText[],
247
+ ): void {
248
+ if (tokens.length < 6) {
249
+ warnings.push({ code: 'invalid-text', message: `Line ${lineIndex + 1}: TEXT requires x, y, alignment, size, and text` });
250
+ return;
251
+ }
252
+ const point = parsePoint(tokens[1], tokens[2]);
253
+ if (point === null) {
254
+ warnings.push({ code: 'invalid-text', message: `Line ${lineIndex + 1}: TEXT has malformed coordinates` });
255
+ return;
256
+ }
257
+ const raw = tokens.slice(5).join(' ');
258
+ if (raw.startsWith('!')) {
259
+ directives.push(decodeText(raw.slice(1).trim()));
260
+ return;
261
+ }
262
+ texts.push({ point, text: decodeText(raw.startsWith(';') ? raw.slice(1).trim() : raw) });
263
+ }
264
+
265
+ function buildSymbolComponent(
266
+ symbol: MutableLtspiceSymbol,
267
+ usedIds: Map<string, number>,
268
+ warnings: Warning[],
269
+ ): Component {
270
+ const normalizedName = normalizeLtspiceSymbolName(symbol.sourceName);
271
+ const def = lookupLtspiceSymbolDef(symbol.sourceName);
272
+ const baseName = getAttr(symbol.attrs, 'InstName') ?? `${normalizedName}${symbol.index + 1}`;
273
+ const id = uniqueId(baseName, usedIds);
274
+ const terminals = (def?.terminals ?? []).map((terminal) => ({
275
+ name: terminal.name,
276
+ position: mapLtspiceTerminal(terminal.local, symbol.placement, symbol.orientation),
277
+ }));
278
+
279
+ if (def === undefined) {
280
+ warnings.push({
281
+ code: 'unknown-ltspice-symbol',
282
+ message: `${id}: unsupported LTspice symbol "${symbol.sourceName}"`,
283
+ componentId: id,
284
+ });
285
+ }
286
+
287
+ return {
288
+ id,
289
+ kind: def?.kind ?? 'unsupported',
290
+ name: baseName,
291
+ origin: symbol.placement,
292
+ rotation: rotationFromLtspiceOrientation(symbol.orientation),
293
+ flipped: symbol.orientation.toUpperCase().startsWith('M'),
294
+ terminals,
295
+ properties: buildProperties(symbol.attrs, def, symbol.sourceName),
296
+ sourceTypeName: `ltspice:${normalizedName}`,
297
+ };
298
+ }
299
+
300
+ function buildFlagComponent(
301
+ flag: LtspiceFlag,
302
+ polarity: string | undefined,
303
+ usedIds: Map<string, number>,
304
+ ): Component {
305
+ const isGround = flag.name === '0';
306
+ const isJack = polarity !== undefined;
307
+ const baseName = isGround ? 'GND' : flag.name;
308
+ const properties: Record<string, PropertyValue> = { Name: flag.name };
309
+ if (polarity !== undefined) {
310
+ properties.polarity = polarity;
311
+ properties.connector = '1/4" TS jack';
312
+ }
313
+
314
+ return {
315
+ id: uniqueId(baseName, usedIds),
316
+ kind: isJack ? 'jack' : isGround ? 'ground' : 'named-wire',
317
+ name: baseName,
318
+ origin: flag.point,
319
+ rotation: 0,
320
+ flipped: false,
321
+ terminals: [{ name: isJack ? 'tip' : 't', position: flag.point }],
322
+ properties,
323
+ sourceTypeName: isJack ? ltspiceJackSourceType(polarity) : 'ltspice:FLAG',
324
+ };
325
+ }
326
+
327
+ function ltspiceJackSourceType(polarity: string | undefined): string {
328
+ return polarity?.toLowerCase() === 'out' ? 'ltspice:OutputJack' : 'ltspice:InputJack';
329
+ }
330
+
331
+ function buildTextComponent(text: LtspiceText, index: number, usedIds: Map<string, number>): Component {
332
+ const id = uniqueId(`TEXT${index + 1}`, usedIds);
333
+ return {
334
+ id,
335
+ kind: 'label',
336
+ name: id,
337
+ origin: text.point,
338
+ rotation: 0,
339
+ flipped: false,
340
+ terminals: [],
341
+ properties: { Text: text.text },
342
+ sourceTypeName: 'ltspice:TEXT',
343
+ };
344
+ }
345
+
346
+ function buildProperties(
347
+ attrs: ReadonlyMap<string, string>,
348
+ def: LtspiceSymbolDef | undefined,
349
+ rawSymbolName: string,
350
+ ): Readonly<Record<string, PropertyValue>> {
351
+ const properties: Record<string, PropertyValue> = {};
352
+ for (const [key, value] of attrs) {
353
+ properties[key] = value;
354
+ }
355
+
356
+ const value = getAttr(attrs, 'Value');
357
+ if (value !== null && def !== undefined) {
358
+ if (def.valueProperty !== null) {
359
+ properties[def.valueProperty] = parseQuantity(value) ?? value;
360
+ }
361
+ if (def.modelFromValue) {
362
+ properties.model = value;
363
+ }
364
+ }
365
+
366
+ if (def?.modelFromSymbolPath === true && properties.model === undefined) {
367
+ properties.model = extractModelFromSymbolPath(rawSymbolName);
368
+ }
369
+
370
+ return properties;
371
+ }
372
+
373
+ function getAttr(attrs: ReadonlyMap<string, string>, wanted: string): string | null {
374
+ for (const [key, value] of attrs) {
375
+ if (key.toLowerCase() === wanted.toLowerCase()) {
376
+ return value;
377
+ }
378
+ }
379
+ return null;
380
+ }
381
+
382
+ function buildRawAttributes(version: string, sheet: string): Readonly<Record<string, string>> {
383
+ const attrs: Record<string, string> = { format: 'ltspice-asc' };
384
+ if (version.length > 0) {
385
+ attrs.version = version;
386
+ }
387
+ if (sheet.length > 0) {
388
+ attrs.sheet = sheet;
389
+ }
390
+ return attrs;
391
+ }
392
+
393
+ function parsePoint(xText: string | undefined, yText: string | undefined): Point | null {
394
+ const x = Number.parseInt(xText ?? '', 10);
395
+ const y = Number.parseInt(yText ?? '', 10);
396
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
397
+ return null;
398
+ }
399
+ // Scale LTspice grid units down to the document-level scale used by the rest
400
+ // of the library — see LTSPICE_COORD_SCALE in catalog.ts for the rationale.
401
+ return { x: x * LTSPICE_COORD_SCALE, y: y * LTSPICE_COORD_SCALE };
402
+ }
403
+
404
+ function pointKey(point: Point): string {
405
+ return `${point.x},${point.y}`;
406
+ }
407
+
408
+ function uniqueId(baseName: string, usedIds: Map<string, number>): string {
409
+ const sanitized = baseName.replace(/\s+/g, '-').replace(/[^A-Za-z0-9_-]/g, '');
410
+ const base = sanitized.length > 0 ? sanitized : 'component';
411
+ const count = usedIds.get(base) ?? 0;
412
+ usedIds.set(base, count + 1);
413
+ return count === 0 ? base : `${base}-${count + 1}`;
414
+ }
415
+
416
+ function decodeText(text: string): string {
417
+ return text.replaceAll('\\n', '\n');
418
+ }
419
+
420
+ function rotationFromLtspiceOrientation(orientation: string): Component['rotation'] {
421
+ const match = orientation.toUpperCase().match(/[MR](0|90|180|270)$/);
422
+ if (match?.[1] === '90') {
423
+ return 1;
424
+ }
425
+ if (match?.[1] === '180') {
426
+ return 2;
427
+ }
428
+ if (match?.[1] === '270') {
429
+ return 3;
430
+ }
431
+ return 0;
432
+ }
@@ -0,0 +1,169 @@
1
+ import { propertyValueForSourceAttribute } from '../../model/properties';
2
+ import type { CircuitDocument, Component, Point, PropertyValue, Rotation, Wire } from '../../model/types';
3
+ import { LTSPICE_COORD_SCALE, normalizeLtspiceSymbolName } from './catalog';
4
+
5
+ export type SerializeLtspiceAscOptions = Readonly<{
6
+ version?: string;
7
+ sheet?: string;
8
+ }>;
9
+
10
+ const KIND_TO_SYMBOL: Readonly<Record<string, string>> = {
11
+ resistor: 'res',
12
+ capacitor: 'cap',
13
+ inductor: 'ind',
14
+ diode: 'diode',
15
+ led: 'led',
16
+ 'voltage-source': 'voltage',
17
+ 'current-source': 'current',
18
+ bjt: 'npn',
19
+ jfet: 'njf',
20
+ mosfet: 'nmos',
21
+ };
22
+
23
+ export function serializeLtspiceAsc(
24
+ doc: CircuitDocument,
25
+ options: SerializeLtspiceAscOptions = {},
26
+ ): string {
27
+ const version = options.version ?? doc.rawAttributes.version ?? '4';
28
+ const sheet = options.sheet ?? doc.rawAttributes.sheet ?? '1 880 680';
29
+ const lines: string[] = [`Version ${version}`, `SHEET ${sheet}`];
30
+
31
+ for (const wire of doc.wires) {
32
+ lines.push(wireLine(wire));
33
+ }
34
+
35
+ for (const component of doc.components) {
36
+ lines.push(...componentLines(component));
37
+ }
38
+
39
+ for (const directive of doc.directives) {
40
+ lines.push(`TEXT 0 0 Left 2 !${encodeText(directive)}`);
41
+ }
42
+
43
+ return `${lines.join('\n')}\n`;
44
+ }
45
+
46
+ function componentLines(component: Component): readonly string[] {
47
+ if (component.kind === 'ground' || component.kind === 'named-wire') {
48
+ return flagLines(component);
49
+ }
50
+ if (component.kind === 'jack') {
51
+ return jackLines(component);
52
+ }
53
+ if (component.kind === 'label') {
54
+ return labelLines(component);
55
+ }
56
+
57
+ const symbolName = ltspiceSymbolName(component);
58
+ const lines = [
59
+ `SYMBOL ${symbolName} ${scaledCoordinate(component.origin.x)} ${scaledCoordinate(component.origin.y)} ${orientation(component.rotation, component.flipped)}`,
60
+ `SYMATTR InstName ${component.name}`,
61
+ ];
62
+ const value = componentValue(component);
63
+ if (value !== null) {
64
+ lines.push(`SYMATTR Value ${value}`);
65
+ }
66
+ for (const [key, property] of Object.entries(component.properties)) {
67
+ if (shouldSkipSymattr(key)) {
68
+ continue;
69
+ }
70
+ const serialized = propertyValueForSourceAttribute(property);
71
+ if (serialized !== null) {
72
+ lines.push(`SYMATTR ${key} ${serialized}`);
73
+ }
74
+ }
75
+ return lines;
76
+ }
77
+
78
+ function flagLines(component: Component): readonly string[] {
79
+ const terminal = component.terminals[0];
80
+ const point = terminal?.position ?? component.origin;
81
+ const name = component.kind === 'ground'
82
+ ? '0'
83
+ : propertyText(component.properties.Name) ?? component.name;
84
+ return [`FLAG ${scaledCoordinate(point.x)} ${scaledCoordinate(point.y)} ${name}`];
85
+ }
86
+
87
+ function jackLines(component: Component): readonly string[] {
88
+ const terminal = component.terminals[0];
89
+ const point = terminal?.position ?? component.origin;
90
+ const name = propertyText(component.properties.Name) ?? component.name;
91
+ const polarity = propertyText(component.properties.polarity)
92
+ ?? (component.sourceTypeName?.toLowerCase().includes('output') === true ? 'Out' : 'In');
93
+ return [
94
+ `FLAG ${scaledCoordinate(point.x)} ${scaledCoordinate(point.y)} ${name}`,
95
+ `IOPIN ${scaledCoordinate(point.x)} ${scaledCoordinate(point.y)} ${polarity}`,
96
+ ];
97
+ }
98
+
99
+ function labelLines(component: Component): readonly string[] {
100
+ const text = propertyText(component.properties.Text) ?? component.name;
101
+ return [`TEXT ${scaledCoordinate(component.origin.x)} ${scaledCoordinate(component.origin.y)} Left 2 ;${encodeText(text)}`];
102
+ }
103
+
104
+ function wireLine(wire: Wire): string {
105
+ return [
106
+ 'WIRE',
107
+ scaledCoordinate(wire.endpoints[0].x),
108
+ scaledCoordinate(wire.endpoints[0].y),
109
+ scaledCoordinate(wire.endpoints[1].x),
110
+ scaledCoordinate(wire.endpoints[1].y),
111
+ ].join(' ');
112
+ }
113
+
114
+ function ltspiceSymbolName(component: Component): string {
115
+ if (component.sourceTypeName?.startsWith('ltspice:') === true) {
116
+ const source = component.sourceTypeName.slice('ltspice:'.length);
117
+ if (source !== 'flag' && source !== 'text' && source.length > 0) {
118
+ return normalizeLtspiceSymbolName(source);
119
+ }
120
+ }
121
+ return KIND_TO_SYMBOL[component.kind] ?? 'unknown';
122
+ }
123
+
124
+ function componentValue(component: Component): string | null {
125
+ const keys = ['Value', 'R', 'Resistance', 'C', 'Capacitance', 'L', 'Inductance', 'V', 'Voltage', 'I', 'Current', 'model', 'Model'];
126
+ for (const key of keys) {
127
+ const value = component.properties[key];
128
+ const serialized = value === undefined ? null : propertyValueForSourceAttribute(value);
129
+ if (serialized !== null && serialized.trim().length > 0) {
130
+ return serialized;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function propertyText(value: PropertyValue | undefined): string | null {
137
+ const serialized = value === undefined ? null : propertyValueForSourceAttribute(value);
138
+ return serialized === null || serialized.trim().length === 0 ? null : serialized;
139
+ }
140
+
141
+ function shouldSkipSymattr(key: string): boolean {
142
+ return new Set([
143
+ 'Name',
144
+ 'Value',
145
+ 'R',
146
+ 'Resistance',
147
+ 'C',
148
+ 'Capacitance',
149
+ 'L',
150
+ 'Inductance',
151
+ 'V',
152
+ 'Voltage',
153
+ 'I',
154
+ 'Current',
155
+ ]).has(key);
156
+ }
157
+
158
+ function orientation(rotation: Rotation, flipped: boolean): string {
159
+ const prefix = flipped ? 'M' : 'R';
160
+ return `${prefix}${rotation * 90}`;
161
+ }
162
+
163
+ function scaledCoordinate(value: number): number {
164
+ return Math.round(value / LTSPICE_COORD_SCALE);
165
+ }
166
+
167
+ function encodeText(text: string): string {
168
+ return text.replaceAll('\n', '\\n');
169
+ }