@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,375 @@
1
+ import { getPinNode, resolveConnectivity, type Connectivity, type NodeId } from './connectivity';
2
+ import { parseQuantity } from './quantity';
3
+ import { propertyQuantityValue, propertyStringValue } from './properties';
4
+ import type { CircuitDocument, Component, ComponentKind, ParsedQuantity } from './types';
5
+
6
+ export type SpiceLetter = 'R' | 'C' | 'L' | 'D' | 'Q' | 'J' | 'M' | 'V' | 'I';
7
+
8
+ export type NetlistComponent = Readonly<{
9
+ id: string;
10
+ kind: ComponentKind;
11
+ spiceLetter: SpiceLetter | null;
12
+ value: ParsedQuantity | null;
13
+ nodes: readonly NodeId[];
14
+ model: string | null;
15
+ extras: Readonly<Record<string, string>>;
16
+ }>;
17
+
18
+ export type NetlistView = Readonly<{
19
+ components: readonly NetlistComponent[];
20
+ nodeCount: number;
21
+ groundNodeId: NodeId | null;
22
+ directives: readonly string[];
23
+ warnings: readonly string[];
24
+ }>;
25
+
26
+ const SPICE_LETTER: Partial<Record<ComponentKind, SpiceLetter>> = {
27
+ resistor: 'R',
28
+ 'variable-resistor': 'R',
29
+ capacitor: 'C',
30
+ inductor: 'L',
31
+ diode: 'D',
32
+ led: 'D',
33
+ bjt: 'Q',
34
+ jfet: 'J',
35
+ mosfet: 'M',
36
+ 'voltage-source': 'V',
37
+ 'current-source': 'I',
38
+ battery: 'V',
39
+ rail: 'V',
40
+ };
41
+
42
+ const NODE_ORDER: Partial<Record<ComponentKind, readonly string[]>> = {
43
+ resistor: ['a', 'b'],
44
+ 'variable-resistor': ['a', 'b'],
45
+ capacitor: ['a', 'b'],
46
+ inductor: ['a', 'b'],
47
+ diode: ['anode', 'cathode'],
48
+ led: ['anode', 'cathode'],
49
+ 'tube-diode': ['anode', 'cathode'],
50
+ bjt: ['collector', 'base', 'emitter'],
51
+ jfet: ['drain', 'gate', 'source'],
52
+ mosfet: ['drain', 'gate', 'source', 'body'],
53
+ 'voltage-source': ['+', '-'],
54
+ 'current-source': ['+', '-'],
55
+ battery: ['+', '-'],
56
+ // Rails have a single terminal "t" that's hot; the other side is implicit ground.
57
+ // toNetlistView appends groundNodeId after node resolution to produce a SPICE-shaped 2-node row.
58
+ rail: ['t'],
59
+ opamp: ['vin+', 'vin-', 'vout', 'vcc', 'vee'],
60
+ triode: ['plate', 'grid', 'cathode'],
61
+ pentode: ['plate', 'screen', 'grid', 'cathode', 'suppressor'],
62
+ };
63
+
64
+ const VALUE_PROPERTY: Partial<Record<ComponentKind, string>> = {
65
+ resistor: 'R',
66
+ 'variable-resistor': 'R',
67
+ capacitor: 'C',
68
+ inductor: 'L',
69
+ 'voltage-source': 'V',
70
+ 'current-source': 'I',
71
+ battery: 'V',
72
+ rail: 'V',
73
+ };
74
+
75
+ // Source-format aliases for the value property (e.g. LiveSPICE stores R as "Resistance").
76
+ // Keep in sync with the alias lists in src/model/validation.ts until both fold into the catalog.
77
+ const VALUE_PROPERTY_ALIASES: Partial<Record<ComponentKind, readonly string[]>> = {
78
+ resistor: ['Resistance', 'resistance'],
79
+ 'variable-resistor': ['Resistance', 'resistance'],
80
+ capacitor: ['Capacitance', 'capacitance'],
81
+ inductor: ['Inductance', 'inductance'],
82
+ 'voltage-source': ['Voltage', 'voltage'],
83
+ 'current-source': ['Current', 'current'],
84
+ battery: ['Voltage', 'voltage'],
85
+ rail: ['Voltage', 'voltage'],
86
+ };
87
+
88
+ const REQUIRES_VALUE: ReadonlySet<ComponentKind> = new Set<ComponentKind>([
89
+ 'resistor',
90
+ 'capacitor',
91
+ 'inductor',
92
+ 'voltage-source',
93
+ 'current-source',
94
+ 'battery',
95
+ 'rail',
96
+ ]);
97
+
98
+ const HAS_MODEL: ReadonlySet<ComponentKind> = new Set<ComponentKind>([
99
+ 'diode',
100
+ 'led',
101
+ 'bjt',
102
+ 'jfet',
103
+ 'mosfet',
104
+ 'opamp',
105
+ 'triode',
106
+ 'pentode',
107
+ 'tube-diode',
108
+ ]);
109
+
110
+ const SKIP_KINDS: ReadonlySet<ComponentKind> = new Set<ComponentKind>([
111
+ 'ground',
112
+ 'label',
113
+ 'named-wire',
114
+ 'port',
115
+ 'jack',
116
+ ]);
117
+
118
+ const MODEL_PROPERTY_NAMES: readonly string[] = [
119
+ 'model',
120
+ 'Model',
121
+ 'modelName',
122
+ 'ModelName',
123
+ 'partNumber',
124
+ 'PartNumber',
125
+ 'Type',
126
+ ];
127
+
128
+ export function getSpiceLetter(kind: ComponentKind): SpiceLetter | null {
129
+ return SPICE_LETTER[kind] ?? null;
130
+ }
131
+
132
+ export function getSpiceNodeOrder(kind: ComponentKind): readonly string[] | null {
133
+ return NODE_ORDER[kind] ?? null;
134
+ }
135
+
136
+ export function kindForSpiceLetter(letter: SpiceLetter): ComponentKind {
137
+ switch (letter) {
138
+ case 'R': return 'resistor';
139
+ case 'C': return 'capacitor';
140
+ case 'L': return 'inductor';
141
+ case 'D': return 'diode';
142
+ case 'Q': return 'bjt';
143
+ case 'J': return 'jfet';
144
+ case 'M': return 'mosfet';
145
+ case 'V': return 'voltage-source';
146
+ case 'I': return 'current-source';
147
+ }
148
+ }
149
+
150
+ export function toNetlistView(doc: CircuitDocument, precomputed?: Connectivity): NetlistView {
151
+ const connectivity = precomputed ?? resolveConnectivity(doc);
152
+ const components: NetlistComponent[] = [];
153
+ const warnings: string[] = [];
154
+
155
+ for (const component of doc.components) {
156
+ if (SKIP_KINDS.has(component.kind)) {
157
+ continue;
158
+ }
159
+
160
+ if (component.kind === 'unsupported') {
161
+ warnings.push(
162
+ `${component.id}: unsupported source type ${component.sourceTypeName ?? 'unknown'} — skipped from netlist`,
163
+ );
164
+ continue;
165
+ }
166
+
167
+ // Components that need a SPICE subcircuit (opamps, pots, tubes, etc.) are signaled
168
+ // structurally via `spiceLetter: null` — no per-component warning needed.
169
+
170
+ const spiceLetter = SPICE_LETTER[component.kind] ?? null;
171
+ const expectedOrder = spiceLetter === null && component.terminals.length === 0
172
+ ? null
173
+ : NODE_ORDER[component.kind] ?? null;
174
+ const ordered = orderedNodes(component, connectivity, expectedOrder);
175
+ warnings.push(...ordered.warnings);
176
+
177
+ let nodes = ordered.nodes;
178
+ if (component.kind === 'rail') {
179
+ // Append implicit ground as the negative terminal so the SPICE row is well-shaped.
180
+ if (connectivity.groundNodeId === null) {
181
+ warnings.push(`${component.id} (rail): no ground node in document — cannot anchor implicit return`);
182
+ } else {
183
+ nodes = [...nodes, connectivity.groundNodeId];
184
+ }
185
+ }
186
+
187
+ const valueResult = extractValue(component);
188
+ const value = valueResult.value;
189
+ if (value === null && REQUIRES_VALUE.has(component.kind) && !valueResult.present) {
190
+ warnings.push(`${component.id} (${component.kind}): missing required value property`);
191
+ }
192
+
193
+ const model = extractModel(component);
194
+ const consumed = consumedPropertyKeys(component.kind);
195
+ const extras = extractExtras(component, consumed);
196
+
197
+ components.push({
198
+ id: component.id,
199
+ kind: component.kind,
200
+ spiceLetter,
201
+ value,
202
+ nodes,
203
+ model,
204
+ extras,
205
+ });
206
+ }
207
+
208
+ return {
209
+ components,
210
+ nodeCount: connectivity.nodeCount,
211
+ groundNodeId: connectivity.groundNodeId,
212
+ directives: doc.directives,
213
+ warnings,
214
+ };
215
+ }
216
+
217
+ function orderedNodes(
218
+ component: Component,
219
+ connectivity: Connectivity,
220
+ expected: readonly string[] | null,
221
+ ): { nodes: readonly NodeId[]; warnings: readonly string[] } {
222
+ const warnings: string[] = [];
223
+
224
+ if (expected === null) {
225
+ const nodes = collectDeclarationOrder(component, connectivity, warnings);
226
+ return { nodes, warnings };
227
+ }
228
+
229
+ const byName = new Map(component.terminals.map((t) => [t.name, t]));
230
+ const missing: string[] = [];
231
+ const ordered: NodeId[] = [];
232
+
233
+ for (const name of expected) {
234
+ const terminal = byName.get(name);
235
+ if (terminal === undefined) {
236
+ missing.push(name);
237
+ continue;
238
+ }
239
+ const node = getPinNode(connectivity, { componentId: component.id, terminalName: name });
240
+ if (node === undefined) {
241
+ warnings.push(`${component.id}: terminal "${name}" has no resolved node`);
242
+ } else {
243
+ ordered.push(node);
244
+ }
245
+ }
246
+
247
+ if (missing.length > 0) {
248
+ warnings.push(
249
+ `${component.id} (${component.kind}): expected terminals [${expected.join(', ')}], missing [${missing.join(', ')}] — falling back to declaration order`,
250
+ );
251
+ const fallback = collectDeclarationOrder(component, connectivity, warnings);
252
+ return { nodes: fallback, warnings };
253
+ }
254
+
255
+ return { nodes: ordered, warnings };
256
+ }
257
+
258
+ function collectDeclarationOrder(
259
+ component: Component,
260
+ connectivity: Connectivity,
261
+ warnings: string[],
262
+ ): readonly NodeId[] {
263
+ const nodes: NodeId[] = [];
264
+ for (const terminal of component.terminals) {
265
+ const node = getPinNode(connectivity, { componentId: component.id, terminalName: terminal.name });
266
+ if (node === undefined) {
267
+ warnings.push(`${component.id}: terminal "${terminal.name}" has no resolved node`);
268
+ } else {
269
+ nodes.push(node);
270
+ }
271
+ }
272
+ return nodes;
273
+ }
274
+
275
+ function extractValue(component: Component): { value: ParsedQuantity | null; present: boolean } {
276
+ const baseName = VALUE_PROPERTY[component.kind];
277
+ if (baseName === undefined) {
278
+ return { value: null, present: false };
279
+ }
280
+
281
+ const aliases = VALUE_PROPERTY_ALIASES[component.kind] ?? [];
282
+ const variants = uniqueVariants([
283
+ baseName,
284
+ baseName.toLowerCase(),
285
+ baseName.toUpperCase(),
286
+ capitalize(baseName),
287
+ ...aliases,
288
+ 'value',
289
+ 'Value',
290
+ ]);
291
+
292
+ for (const name of variants) {
293
+ const raw = component.properties[name];
294
+ if (raw === undefined) {
295
+ continue;
296
+ }
297
+ if (typeof raw === 'string') {
298
+ return { value: parseQuantity(raw), present: raw.trim().length > 0 };
299
+ }
300
+ return { value: propertyQuantityValue(raw), present: true };
301
+ }
302
+ return { value: null, present: false };
303
+ }
304
+
305
+ function extractModel(component: Component): string | null {
306
+ if (!HAS_MODEL.has(component.kind)) {
307
+ return null;
308
+ }
309
+ for (const name of MODEL_PROPERTY_NAMES) {
310
+ const raw = component.properties[name];
311
+ if (typeof raw === 'string' && raw.length > 0) {
312
+ return raw;
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+
318
+ function consumedPropertyKeys(kind: ComponentKind): ReadonlySet<string> {
319
+ const consumed = new Set<string>();
320
+ const baseName = VALUE_PROPERTY[kind];
321
+ if (baseName !== undefined) {
322
+ consumed.add(baseName);
323
+ consumed.add(baseName.toLowerCase());
324
+ consumed.add(baseName.toUpperCase());
325
+ consumed.add(capitalize(baseName));
326
+ for (const alias of VALUE_PROPERTY_ALIASES[kind] ?? []) {
327
+ consumed.add(alias);
328
+ }
329
+ consumed.add('value');
330
+ consumed.add('Value');
331
+ }
332
+ if (HAS_MODEL.has(kind)) {
333
+ for (const name of MODEL_PROPERTY_NAMES) {
334
+ consumed.add(name);
335
+ }
336
+ }
337
+ return consumed;
338
+ }
339
+
340
+ function extractExtras(
341
+ component: Component,
342
+ consumed: ReadonlySet<string>,
343
+ ): Readonly<Record<string, string>> {
344
+ const extras: Record<string, string> = {};
345
+ for (const [key, value] of Object.entries(component.properties)) {
346
+ if (consumed.has(key)) {
347
+ continue;
348
+ }
349
+ const text = propertyStringValue(value);
350
+ if (text !== null) {
351
+ extras[key] = text;
352
+ }
353
+ }
354
+ return extras;
355
+ }
356
+
357
+ function uniqueVariants(input: readonly string[]): readonly string[] {
358
+ const seen = new Set<string>();
359
+ const out: string[] = [];
360
+ for (const name of input) {
361
+ if (seen.has(name)) {
362
+ continue;
363
+ }
364
+ seen.add(name);
365
+ out.push(name);
366
+ }
367
+ return out;
368
+ }
369
+
370
+ function capitalize(s: string): string {
371
+ if (s.length === 0) {
372
+ return s;
373
+ }
374
+ return s.charAt(0).toUpperCase() + s.slice(1);
375
+ }
@@ -0,0 +1,101 @@
1
+ import { parseQuantity } from './quantity';
2
+ import type { ParsedQuantity, PropertyObject, PropertyValue } from './types';
3
+
4
+ export function isParsedQuantity(value: unknown): value is ParsedQuantity {
5
+ if (!isUnknownRecord(value)) {
6
+ return false;
7
+ }
8
+ return typeof value['raw'] === 'string' &&
9
+ typeof value['value'] === 'number' &&
10
+ typeof value['unit'] === 'string';
11
+ }
12
+
13
+ export function isPropertyObject(value: PropertyValue | undefined): value is PropertyObject {
14
+ return isUnknownRecord(value) && !Array.isArray(value) && !isParsedQuantity(value);
15
+ }
16
+
17
+ export function propertyStringValue(value: PropertyValue | undefined): string | null {
18
+ if (value === undefined || value === null) {
19
+ return null;
20
+ }
21
+ if (typeof value === 'string') {
22
+ return value;
23
+ }
24
+ if (typeof value === 'number' || typeof value === 'boolean') {
25
+ return String(value);
26
+ }
27
+ if (isParsedQuantity(value)) {
28
+ return value.raw;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export function propertyQuantityValue(value: PropertyValue | undefined): ParsedQuantity | null {
34
+ if (value === undefined || value === null) {
35
+ return null;
36
+ }
37
+ if (isParsedQuantity(value)) {
38
+ return value;
39
+ }
40
+ if (typeof value === 'string') {
41
+ return parseQuantity(value);
42
+ }
43
+ if (typeof value === 'number') {
44
+ return {
45
+ raw: String(value),
46
+ value,
47
+ unit: '',
48
+ };
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export function propertyNumericValue(value: PropertyValue | undefined): number | undefined {
54
+ if (value === undefined || value === null) {
55
+ return undefined;
56
+ }
57
+ if (typeof value === 'number') {
58
+ return Number.isFinite(value) ? value : undefined;
59
+ }
60
+ if (isParsedQuantity(value)) {
61
+ return Number.isFinite(value.value) ? value.value : undefined;
62
+ }
63
+ if (typeof value === 'string') {
64
+ const trimmed = value.trim();
65
+ if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) {
66
+ return undefined;
67
+ }
68
+ const parsed = Number.parseFloat(trimmed);
69
+ return Number.isFinite(parsed) ? parsed : undefined;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ export function propertyBooleanValue(value: PropertyValue | undefined): boolean | undefined {
75
+ if (value === undefined || value === null) {
76
+ return undefined;
77
+ }
78
+ if (typeof value === 'boolean') {
79
+ return value;
80
+ }
81
+ const text = propertyStringValue(value)?.trim().toLowerCase();
82
+ if (text === 'true' || text === '1' || text === 'yes') {
83
+ return true;
84
+ }
85
+ if (text === 'false' || text === '0' || text === 'no') {
86
+ return false;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ export function propertyValueForSourceAttribute(value: PropertyValue): string | null {
92
+ const scalar = propertyStringValue(value);
93
+ if (scalar !== null) {
94
+ return scalar;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
101
+ }
@@ -0,0 +1,173 @@
1
+ import type { ParsedQuantity } from './types';
2
+
3
+ const SI_PREFIXES: Readonly<Record<string, number>> = {
4
+ f: 1e-15,
5
+ p: 1e-12,
6
+ n: 1e-9,
7
+ u: 1e-6,
8
+ 'µ': 1e-6, // MICRO SIGN
9
+ 'μ': 1e-6, // GREEK SMALL LETTER MU — LiveSPICE writes "μF" with this codepoint
10
+ m: 1e-3,
11
+ k: 1e3,
12
+ K: 1e3,
13
+ M: 1e6,
14
+ G: 1e9,
15
+ T: 1e12,
16
+ };
17
+
18
+ const SHORTHAND_MARKERS: Readonly<Record<string, { readonly multiplier: number; readonly impliedUnit?: string }>> = {
19
+ f: { multiplier: 1e-15 },
20
+ p: { multiplier: 1e-12 },
21
+ n: { multiplier: 1e-9 },
22
+ u: { multiplier: 1e-6 },
23
+ 'µ': { multiplier: 1e-6 },
24
+ 'μ': { multiplier: 1e-6 },
25
+ m: { multiplier: 1e-3 },
26
+ k: { multiplier: 1e3 },
27
+ K: { multiplier: 1e3 },
28
+ M: { multiplier: 1e6 },
29
+ G: { multiplier: 1e9 },
30
+ T: { multiplier: 1e12 },
31
+ R: { multiplier: 1, impliedUnit: 'Ω' },
32
+ r: { multiplier: 1, impliedUnit: 'Ω' },
33
+ };
34
+
35
+ const UNIT_ALIASES: Readonly<Record<string, string>> = {
36
+ F: 'F',
37
+ f: 'F',
38
+ H: 'H',
39
+ h: 'H',
40
+ V: 'V',
41
+ v: 'V',
42
+ A: 'A',
43
+ a: 'A',
44
+ W: 'W',
45
+ w: 'W',
46
+ 'Ω': 'Ω', // GREEK CAPITAL LETTER OMEGA — canonical form
47
+ 'Ω': 'Ω', // OHM SIGN — LiveSPICE writes "Ω" with this codepoint; canonicalize
48
+ ohm: 'Ω',
49
+ Ohm: 'Ω',
50
+ OHM: 'Ω',
51
+ ohms: 'Ω',
52
+ Hz: 'Hz',
53
+ HZ: 'Hz',
54
+ hz: 'Hz',
55
+ s: 's',
56
+ S: 's',
57
+ };
58
+
59
+ const QUANTITY_REGEX = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)([A-Za-zµμΩΩ]*)$/;
60
+ const SHORTHAND_QUANTITY_REGEX = /^([+-]?\d+)([fFpPnNuUµμmMkKGTRr])(\d+)([A-Za-zµμΩΩ]*)$/;
61
+ const SPICE_MEG = /^(?:Meg|MEG|meg)/;
62
+
63
+ export function parseQuantity(input: string): ParsedQuantity | null {
64
+ const raw = input.trim();
65
+ if (raw.length === 0) {
66
+ return null;
67
+ }
68
+
69
+ const compact = raw.replace(/\s+/g, '');
70
+ const shorthand = parseShorthandQuantity(raw, compact);
71
+ if (shorthand !== null) {
72
+ return shorthand;
73
+ }
74
+
75
+ const match = compact.match(QUANTITY_REGEX);
76
+ if (!match) {
77
+ return null;
78
+ }
79
+
80
+ const numStr = match[1];
81
+ const suffix = match[2] ?? '';
82
+ if (numStr === undefined) {
83
+ return null;
84
+ }
85
+
86
+ const baseValue = Number.parseFloat(numStr);
87
+ if (!Number.isFinite(baseValue)) {
88
+ return null;
89
+ }
90
+
91
+ const parsed = parseSuffix(suffix);
92
+
93
+ return {
94
+ raw,
95
+ value: baseValue * parsed.multiplier,
96
+ unit: parsed.unit,
97
+ };
98
+ }
99
+
100
+ function parseShorthandQuantity(raw: string, compact: string): ParsedQuantity | null {
101
+ const match = compact.match(SHORTHAND_QUANTITY_REGEX);
102
+ if (match === null) {
103
+ return null;
104
+ }
105
+
106
+ const whole = match[1];
107
+ const marker = match[2];
108
+ const fractional = match[3];
109
+ const trailingUnit = match[4] ?? '';
110
+ if (whole === undefined || marker === undefined || fractional === undefined) {
111
+ return null;
112
+ }
113
+
114
+ const markerDef = SHORTHAND_MARKERS[marker];
115
+ if (markerDef === undefined) {
116
+ return null;
117
+ }
118
+
119
+ const value = Number.parseFloat(`${whole}.${fractional}`);
120
+ if (!Number.isFinite(value)) {
121
+ return null;
122
+ }
123
+
124
+ const aliasedTrailingUnit = UNIT_ALIASES[trailingUnit];
125
+ const unit = trailingUnit.length > 0
126
+ ? aliasedTrailingUnit ?? trailingUnit
127
+ : markerDef.impliedUnit ?? '';
128
+
129
+ return {
130
+ raw,
131
+ value: value * markerDef.multiplier,
132
+ unit,
133
+ };
134
+ }
135
+
136
+ function parseSuffix(suffix: string): { multiplier: number; unit: string } {
137
+ if (suffix.length === 0) {
138
+ return { multiplier: 1, unit: '' };
139
+ }
140
+
141
+ // SPICE convention: "Meg" / "MEG" / "meg" = 1e6, distinct from milli "m".
142
+ // Match before the single-char prefix loop so "meg" doesn't get read as milli + "eg".
143
+ const megMatch = SPICE_MEG.exec(suffix);
144
+ if (megMatch) {
145
+ const rest = suffix.slice(megMatch[0].length);
146
+ if (rest.length === 0) {
147
+ return { multiplier: 1e6, unit: '' };
148
+ }
149
+ const restUnit = UNIT_ALIASES[rest];
150
+ return { multiplier: 1e6, unit: restUnit ?? rest };
151
+ }
152
+
153
+ const first = suffix.charAt(0);
154
+ const rest = suffix.slice(1);
155
+ const prefixMultiplier = SI_PREFIXES[first];
156
+
157
+ if (prefixMultiplier !== undefined) {
158
+ if (rest.length === 0) {
159
+ return { multiplier: prefixMultiplier, unit: '' };
160
+ }
161
+ const restUnit = UNIT_ALIASES[rest];
162
+ if (restUnit !== undefined) {
163
+ return { multiplier: prefixMultiplier, unit: restUnit };
164
+ }
165
+ }
166
+
167
+ const fullUnit = UNIT_ALIASES[suffix];
168
+ if (fullUnit !== undefined) {
169
+ return { multiplier: 1, unit: fullUnit };
170
+ }
171
+
172
+ return { multiplier: 1, unit: suffix };
173
+ }