@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,1410 @@
1
+ import {
2
+ any_circuit_element,
3
+ type AnyCircuitElement,
4
+ type AnyCircuitElementInput,
5
+ type CircuitJson as OfficialCircuitJson,
6
+ } from 'circuit-json';
7
+ import { getPinNode, resolveConnectivity, type Connectivity, type NodeId } from '../../model/connectivity';
8
+ import { propertyQuantityValue, propertyStringValue } from '../../model/properties';
9
+ import { parseQuantity } from '../../model/quantity';
10
+ import type {
11
+ CircuitDocument,
12
+ Component,
13
+ ComponentKind,
14
+ ParsedQuantity,
15
+ Point,
16
+ PropertyValue,
17
+ Rotation,
18
+ Terminal,
19
+ Warning,
20
+ Wire,
21
+ } from '../../model/types';
22
+
23
+ export type CircuitJsonExportTarget = 'tscircuit';
24
+
25
+ export type CircuitJsonExportOptions = Readonly<{
26
+ target?: CircuitJsonExportTarget;
27
+ }>;
28
+
29
+ export type CircuitJsonSourceNet = Readonly<{
30
+ type: 'source_net';
31
+ source_net_id: string;
32
+ name: string;
33
+ member_source_group_ids: string[];
34
+ is_power?: boolean;
35
+ is_ground?: boolean;
36
+ is_digital_signal?: boolean;
37
+ is_analog_signal?: boolean;
38
+ is_positive_voltage_source?: boolean;
39
+ }>;
40
+
41
+ export type CircuitJsonSourceComponent = Readonly<{
42
+ type: 'source_component';
43
+ source_component_id: string;
44
+ name: string;
45
+ ftype?: string;
46
+ display_name?: string;
47
+ display_value?: string;
48
+ resistance?: number;
49
+ display_resistance?: string;
50
+ capacitance?: number;
51
+ display_capacitance?: string;
52
+ inductance?: number;
53
+ display_inductance?: string;
54
+ voltage?: number;
55
+ current?: number;
56
+ wave_shape?: 'dc' | 'sine' | 'square' | 'triangle' | 'sawtooth' | 'sinewave';
57
+ transistor_type?: 'npn' | 'pnp';
58
+ channel_type?: 'n' | 'p';
59
+ mosfet_mode?: 'enhancement' | 'depletion';
60
+ max_resistance?: number;
61
+ display_max_resistance?: string;
62
+ manufacturer_part_number?: string;
63
+ }>;
64
+
65
+ export type CircuitJsonSourcePort = Readonly<{
66
+ type: 'source_port';
67
+ source_port_id: string;
68
+ source_component_id: string;
69
+ name: string;
70
+ port_hints: string[];
71
+ provides_ground?: boolean;
72
+ requires_ground?: boolean;
73
+ provides_power?: boolean;
74
+ requires_power?: boolean;
75
+ provides_voltage?: number;
76
+ }>;
77
+
78
+ export type CircuitJsonSourceTrace = Readonly<{
79
+ type: 'source_trace';
80
+ source_trace_id: string;
81
+ connected_source_port_ids: string[];
82
+ connected_source_net_ids: string[];
83
+ display_name?: string;
84
+ }>;
85
+
86
+ export type CircuitJsonElement =
87
+ AnyCircuitElement;
88
+
89
+ export type CircuitJson = OfficialCircuitJson;
90
+ export type { AnyCircuitElement, AnyCircuitElementInput };
91
+
92
+ export type CircuitJsonExport = Readonly<{
93
+ elements: CircuitJson;
94
+ warnings: readonly string[];
95
+ }>;
96
+
97
+ export type CircuitJsonSchemaValidationIssue = Readonly<{
98
+ code: 'circuit-json-schema-invalid';
99
+ message: string;
100
+ path?: string;
101
+ }>;
102
+
103
+ export type CircuitJsonSchemaValidationResult =
104
+ | Readonly<{
105
+ valid: true;
106
+ elements: CircuitJson;
107
+ errors: readonly [];
108
+ }>
109
+ | Readonly<{
110
+ valid: false;
111
+ errors: readonly CircuitJsonSchemaValidationIssue[];
112
+ }>;
113
+
114
+ export type ParseCircuitJsonDocumentOptions = Readonly<{
115
+ filename?: string;
116
+ }>;
117
+
118
+ type JsonRecord = Readonly<Record<string, unknown>>;
119
+
120
+ type SourceComponentRecord = Readonly<{
121
+ sourceComponentId: string;
122
+ componentId: string;
123
+ name: string;
124
+ ftype: string | null;
125
+ record: JsonRecord;
126
+ }>;
127
+
128
+ type SourcePortRecord = Readonly<{
129
+ sourcePortId: string;
130
+ componentSourceId: string;
131
+ terminalName: string;
132
+ record: JsonRecord;
133
+ }>;
134
+
135
+ type SchematicComponentRecord = Readonly<{
136
+ sourceComponentId: string;
137
+ center: Point;
138
+ }>;
139
+
140
+ type SchematicPortRecord = Readonly<{
141
+ sourcePortId: string;
142
+ center: Point;
143
+ }>;
144
+
145
+ type MutableComponentBuild = {
146
+ readonly sourceComponent: SourceComponentRecord;
147
+ readonly ports: readonly SourcePortRecord[];
148
+ readonly origin: Point;
149
+ readonly terminals: readonly Terminal[];
150
+ };
151
+
152
+ type QuantityLookup = Readonly<{
153
+ value: ParsedQuantity;
154
+ }>;
155
+
156
+ type QuantityKey = 'resistance' | 'capacitance' | 'inductance' | 'voltage' | 'current';
157
+ type CircuitJsonSchematicPortDirection = 'up' | 'down' | 'left' | 'right';
158
+
159
+ const VALUE_PROPERTY_NAMES: Readonly<Record<QuantityKey, readonly string[]>> = {
160
+ resistance: ['R', 'Resistance', 'resistance', 'value', 'Value'],
161
+ capacitance: ['C', 'Capacitance', 'capacitance', 'value', 'Value'],
162
+ inductance: ['L', 'Inductance', 'inductance', 'value', 'Value'],
163
+ voltage: ['V', 'Voltage', 'voltage', 'value', 'Value'],
164
+ current: ['I', 'Current', 'current', 'value', 'Value'],
165
+ };
166
+
167
+ const MODEL_PROPERTY_NAMES: readonly string[] = [
168
+ 'manufacturerPartNumber',
169
+ 'ManufacturerPartNumber',
170
+ 'manufacturer_part_number',
171
+ 'partNumber',
172
+ 'PartNumber',
173
+ 'model',
174
+ 'Model',
175
+ 'modelName',
176
+ 'ModelName',
177
+ ];
178
+
179
+ const DIRECT_EXPORT_KINDS: ReadonlySet<ComponentKind> = new Set<ComponentKind>([
180
+ 'resistor',
181
+ 'variable-resistor',
182
+ 'capacitor',
183
+ 'inductor',
184
+ 'diode',
185
+ 'led',
186
+ 'bjt',
187
+ 'jfet',
188
+ 'mosfet',
189
+ 'opamp',
190
+ 'potentiometer',
191
+ 'switch',
192
+ 'voltage-source',
193
+ 'current-source',
194
+ 'battery',
195
+ 'ground',
196
+ 'rail',
197
+ 'jack',
198
+ 'port',
199
+ 'ic',
200
+ ]);
201
+
202
+ const SOURCE_ONLY_NET_NAME_KINDS: ReadonlySet<ComponentKind> = new Set<ComponentKind>([
203
+ 'label',
204
+ 'named-wire',
205
+ ]);
206
+
207
+ const TSCIRCUIT_SCHEMATIC_COORD_SCALE = 0.02;
208
+ const DEFAULT_SCHEMATIC_COMPONENT_SIZE = { width: 1.2, height: 0.8 };
209
+
210
+ export function serializeCircuitJsonDocument(
211
+ doc: CircuitDocument,
212
+ _options: CircuitJsonExportOptions = {},
213
+ ): CircuitJsonExport {
214
+ const connectivity = resolveConnectivity(doc);
215
+ const warnings: string[] = [];
216
+ const exportedComponentIds = new Set<string>();
217
+ const sourcePortIdsByNode = new Map<NodeId, string[]>();
218
+ const names = netNames(doc, connectivity);
219
+ const powerNodes = railNodeIds(doc, connectivity);
220
+
221
+ const nets = sourceNetElements(connectivity, names, powerNodes);
222
+ const components: CircuitJsonSourceComponent[] = [];
223
+ const ports: CircuitJsonSourcePort[] = [];
224
+
225
+ for (const component of doc.components) {
226
+ const sourceComponent = sourceComponentElement(component, warnings);
227
+ if (sourceComponent === null) {
228
+ continue;
229
+ }
230
+ exportedComponentIds.add(component.id);
231
+ components.push(sourceComponent);
232
+
233
+ for (const terminal of component.terminals) {
234
+ const nodeId = getPinNode(connectivity, {
235
+ componentId: component.id,
236
+ terminalName: terminal.name,
237
+ });
238
+ const sourcePort = sourcePortElement(component, terminal.name);
239
+ ports.push(sourcePort);
240
+ if (nodeId !== undefined) {
241
+ appendSourcePort(sourcePortIdsByNode, nodeId, sourcePort.source_port_id);
242
+ }
243
+ }
244
+ }
245
+
246
+ const traces = sourceTraceElements(connectivity, names, sourcePortIdsByNode, warnings);
247
+ warnings.push(...sourceOnlyWarnings(doc, exportedComponentIds));
248
+
249
+ const sourceElements = [...sourceProjectMetadataElements(doc), ...nets, ...components, ...ports, ...traces];
250
+ return {
251
+ elements: normalizeCircuitJsonElements([...sourceElements, ...schematicElements(doc, traces)]),
252
+ warnings,
253
+ };
254
+ }
255
+
256
+ export function validateCircuitJsonDocument(source: unknown): CircuitJsonSchemaValidationResult {
257
+ if (!Array.isArray(source)) {
258
+ return {
259
+ valid: false,
260
+ errors: [{
261
+ code: 'circuit-json-schema-invalid',
262
+ message: 'Circuit JSON document must be an array of elements',
263
+ }],
264
+ };
265
+ }
266
+
267
+ const elements: AnyCircuitElement[] = [];
268
+ const errors: CircuitJsonSchemaValidationIssue[] = [];
269
+ for (const [index, element] of source.entries()) {
270
+ const shallowIssue = shallowSchemaIssue(element);
271
+ if (shallowIssue !== null) {
272
+ errors.push({
273
+ code: 'circuit-json-schema-invalid',
274
+ message: shallowIssue,
275
+ path: `[${index}]`,
276
+ });
277
+ continue;
278
+ }
279
+ const result = any_circuit_element.safeParse(element);
280
+ if (result.success) {
281
+ elements.push(result.data);
282
+ continue;
283
+ }
284
+ errors.push({
285
+ code: 'circuit-json-schema-invalid',
286
+ message: summarizeSchemaIssues(result.error.issues),
287
+ path: `[${index}]`,
288
+ });
289
+ }
290
+
291
+ if (errors.length > 0) {
292
+ return { valid: false, errors };
293
+ }
294
+ return { valid: true, elements, errors: [] };
295
+ }
296
+
297
+ export function parseCircuitJsonDocument(
298
+ source: unknown,
299
+ options: ParseCircuitJsonDocumentOptions = {},
300
+ ): CircuitDocument {
301
+ const result = validateCircuitJsonDocument(source);
302
+ if (!result.valid) {
303
+ throw new Error(result.errors.map((error) => `${error.path ?? '<root>'}: ${error.message}`).join('; '));
304
+ }
305
+
306
+ const sourceComponents = new Map<string, SourceComponentRecord>();
307
+ const sourcePorts = new Map<string, SourcePortRecord>();
308
+ const schematicComponents = new Map<string, SchematicComponentRecord>();
309
+ const schematicPorts = new Map<string, SchematicPortRecord>();
310
+ const sourceTraces: JsonRecord[] = [];
311
+ const sourceNets = new Map<string, JsonRecord>();
312
+ const warnings: Warning[] = [];
313
+ const directives: string[] = [];
314
+ let metadataName: string | null = null;
315
+
316
+ for (const element of result.elements) {
317
+ const record = checkedRecord(element);
318
+ const type = stringField(record, 'type');
319
+ switch (type) {
320
+ case 'source_component': {
321
+ const sourceComponentId = requiredStringField(record, 'source_component_id');
322
+ sourceComponents.set(sourceComponentId, {
323
+ sourceComponentId,
324
+ componentId: stripKnownPrefix(sourceComponentId, 'source_component:') ?? sanitizeId(stringField(record, 'name') ?? sourceComponentId),
325
+ name: stringField(record, 'name') ?? stripKnownPrefix(sourceComponentId, 'source_component:') ?? sourceComponentId,
326
+ ftype: stringField(record, 'ftype'),
327
+ record,
328
+ });
329
+ break;
330
+ }
331
+ case 'source_port': {
332
+ const sourcePortId = requiredStringField(record, 'source_port_id');
333
+ const componentSourceId = requiredStringField(record, 'source_component_id');
334
+ sourcePorts.set(sourcePortId, {
335
+ sourcePortId,
336
+ componentSourceId,
337
+ terminalName: stringField(record, 'name') ?? terminalNameFromSourcePortId(sourcePortId),
338
+ record,
339
+ });
340
+ break;
341
+ }
342
+ case 'source_trace':
343
+ sourceTraces.push(record);
344
+ break;
345
+ case 'source_net': {
346
+ sourceNets.set(requiredStringField(record, 'source_net_id'), record);
347
+ break;
348
+ }
349
+ case 'source_project_metadata':
350
+ metadataName = stringField(record, 'name');
351
+ break;
352
+ case 'schematic_component': {
353
+ const sourceComponentId = stringField(record, 'source_component_id');
354
+ const center = pointField(record, 'center');
355
+ if (sourceComponentId !== null && center !== null) {
356
+ schematicComponents.set(sourceComponentId, { sourceComponentId, center });
357
+ }
358
+ break;
359
+ }
360
+ case 'schematic_port': {
361
+ const sourcePortId = requiredStringField(record, 'source_port_id');
362
+ const center = pointField(record, 'center');
363
+ if (center !== null) {
364
+ schematicPorts.set(sourcePortId, { sourcePortId, center });
365
+ }
366
+ break;
367
+ }
368
+ case 'schematic_text': {
369
+ const text = stringField(record, 'text');
370
+ if (text?.startsWith('!') === true) {
371
+ directives.push(text.slice(1).trim());
372
+ }
373
+ break;
374
+ }
375
+ default:
376
+ if (type !== null && !type.startsWith('schematic_')) {
377
+ warnings.push({
378
+ code: 'circuit-json-element-unsupported',
379
+ message: `Circuit JSON element type "${type}" is not represented in CircuitDocument`,
380
+ });
381
+ }
382
+ break;
383
+ }
384
+ }
385
+
386
+ const portsByComponent = groupPortsByComponent(sourcePorts);
387
+ const hasSchematicGeometry = schematicComponents.size > 0 || schematicPorts.size > 0;
388
+ if (!hasSchematicGeometry && sourceComponents.size > 0) {
389
+ warnings.push({
390
+ code: 'circuit-json-layout-synthesized',
391
+ message: 'Circuit JSON source elements did not include schematic geometry; generated deterministic component and terminal positions',
392
+ });
393
+ }
394
+
395
+ const componentBuilds = Array.from(sourceComponents.values()).map((sourceComponent, index) => {
396
+ const ports = portsByComponent.get(sourceComponent.sourceComponentId) ?? [];
397
+ return buildComponentFromCircuitJson(sourceComponent, ports, schematicComponents, schematicPorts, index);
398
+ });
399
+ const components = componentBuilds.map((build) => build.component);
400
+ const terminalPositions = terminalPositionMap(componentBuilds);
401
+ const wires = wireElementsFromSourceTraces(sourceTraces, terminalPositions);
402
+ const netWarnings = warningsForUnconnectedNets(sourceNets, sourceTraces);
403
+
404
+ return {
405
+ metadata: {
406
+ name: metadataName ?? filenameWithoutCircuitJsonExtension(options.filename ?? 'Circuit JSON Import'),
407
+ description: '',
408
+ partNumber: '',
409
+ },
410
+ source: {
411
+ format: 'circuit-json',
412
+ ...(options.filename !== undefined ? { filename: options.filename } : {}),
413
+ },
414
+ components,
415
+ wires,
416
+ directives,
417
+ warnings: [...warnings, ...netWarnings],
418
+ rawAttributes: { format: 'circuit-json' },
419
+ };
420
+ }
421
+
422
+ function sourceProjectMetadataElements(doc: CircuitDocument): AnyCircuitElement[] {
423
+ if (doc.metadata.name.trim().length === 0) {
424
+ return [];
425
+ }
426
+ return [{
427
+ type: 'source_project_metadata',
428
+ name: doc.metadata.name,
429
+ software_used_string: '@vessel-dsp/core',
430
+ }];
431
+ }
432
+
433
+ function sourceNetElements(
434
+ connectivity: Connectivity,
435
+ names: ReadonlyMap<NodeId, string>,
436
+ powerNodes: ReadonlySet<NodeId>,
437
+ ): readonly CircuitJsonSourceNet[] {
438
+ const elements: CircuitJsonSourceNet[] = [];
439
+
440
+ for (let nodeId = 0; nodeId < connectivity.nodeCount; nodeId += 1) {
441
+ const isGround = connectivity.groundNodeId === nodeId;
442
+ const isPower = powerNodes.has(nodeId);
443
+ const name = names.get(nodeId) ?? (isGround ? 'GND' : `N${nodeId}`);
444
+ elements.push({
445
+ type: 'source_net',
446
+ source_net_id: sourceNetId(nodeId),
447
+ name,
448
+ member_source_group_ids: [],
449
+ ...(isGround ? { is_ground: true } : {}),
450
+ ...(isPower ? { is_power: true, is_positive_voltage_source: true } : {}),
451
+ is_analog_signal: true,
452
+ });
453
+ }
454
+
455
+ return elements;
456
+ }
457
+
458
+ function sourceComponentElement(
459
+ component: Component,
460
+ warnings: string[],
461
+ ): CircuitJsonSourceComponent | null {
462
+ if (component.kind === 'unsupported') {
463
+ warnings.push(
464
+ `${component.id} (unsupported): unsupported source type ${component.sourceTypeName ?? 'unknown'} skipped from Circuit JSON export`,
465
+ );
466
+ return null;
467
+ }
468
+ if (SOURCE_ONLY_NET_NAME_KINDS.has(component.kind)) {
469
+ return null;
470
+ }
471
+ if (!DIRECT_EXPORT_KINDS.has(component.kind)) {
472
+ warnings.push(`${component.id} (${component.kind}): no Circuit JSON source-component mapping; skipped`);
473
+ return null;
474
+ }
475
+
476
+ const base = sourceComponentBase(component);
477
+ const manufacturerPartNumber = firstStringProperty(component, MODEL_PROPERTY_NAMES);
478
+
479
+ switch (component.kind) {
480
+ case 'resistor':
481
+ case 'variable-resistor': {
482
+ const resistance = quantity(component, 'resistance');
483
+ if (resistance === null) {
484
+ return missingQuantityComponent(component, 'resistance', warnings, manufacturerPartNumber);
485
+ }
486
+ return {
487
+ ...base,
488
+ ftype: 'simple_resistor',
489
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
490
+ resistance: resistance.value.value,
491
+ display_resistance: resistance.value.raw,
492
+ display_value: resistance.value.raw,
493
+ };
494
+ }
495
+ case 'capacitor': {
496
+ const capacitance = quantity(component, 'capacitance');
497
+ if (capacitance === null) {
498
+ return missingQuantityComponent(component, 'capacitance', warnings, manufacturerPartNumber);
499
+ }
500
+ return {
501
+ ...base,
502
+ ftype: 'simple_capacitor',
503
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
504
+ capacitance: capacitance.value.value,
505
+ display_capacitance: capacitance.value.raw,
506
+ display_value: capacitance.value.raw,
507
+ };
508
+ }
509
+ case 'inductor': {
510
+ const inductance = quantity(component, 'inductance');
511
+ if (inductance === null) {
512
+ return missingQuantityComponent(component, 'inductance', warnings, manufacturerPartNumber);
513
+ }
514
+ return {
515
+ ...base,
516
+ ftype: 'simple_inductor',
517
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
518
+ inductance: inductance.value.value,
519
+ display_inductance: inductance.value.raw,
520
+ display_value: inductance.value.raw,
521
+ };
522
+ }
523
+ case 'diode':
524
+ return {
525
+ ...base,
526
+ ftype: 'simple_diode',
527
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
528
+ };
529
+ case 'led':
530
+ return {
531
+ ...base,
532
+ ftype: 'simple_led',
533
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
534
+ };
535
+ case 'bjt':
536
+ return {
537
+ ...base,
538
+ ftype: 'simple_transistor',
539
+ transistor_type: inferTransistorType(component),
540
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
541
+ };
542
+ case 'jfet':
543
+ warnings.push(
544
+ `${component.id} (jfet): Circuit JSON has no simple_jfet ftype; emitted simple_mosfet depletion-mode source metadata`,
545
+ );
546
+ return {
547
+ ...base,
548
+ ftype: 'simple_mosfet',
549
+ channel_type: inferJfetChannel(component),
550
+ mosfet_mode: 'depletion',
551
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
552
+ };
553
+ case 'mosfet':
554
+ return {
555
+ ...base,
556
+ ftype: 'simple_mosfet',
557
+ channel_type: inferMosfetChannel(component),
558
+ mosfet_mode: inferMosfetMode(component),
559
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
560
+ };
561
+ case 'opamp':
562
+ return {
563
+ ...base,
564
+ ftype: 'simple_op_amp',
565
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
566
+ };
567
+ case 'potentiometer': {
568
+ const resistance = quantity(component, 'resistance');
569
+ if (resistance === null) {
570
+ return missingQuantityComponent(component, 'resistance', warnings, manufacturerPartNumber);
571
+ }
572
+ return {
573
+ ...base,
574
+ ftype: 'simple_potentiometer',
575
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
576
+ max_resistance: resistance.value.value,
577
+ display_max_resistance: resistance.value.raw,
578
+ display_value: resistance.value.raw,
579
+ };
580
+ }
581
+ case 'switch':
582
+ return { ...base, ftype: 'simple_switch' };
583
+ case 'voltage-source':
584
+ case 'battery': {
585
+ const voltage = quantity(component, 'voltage');
586
+ if (voltage === null) {
587
+ return missingQuantityComponent(component, 'voltage', warnings, null);
588
+ }
589
+ return {
590
+ ...base,
591
+ ftype: 'simple_voltage_source',
592
+ voltage: voltage.value.value,
593
+ display_value: voltage.value.raw,
594
+ };
595
+ }
596
+ case 'rail': {
597
+ const voltage = quantity(component, 'voltage');
598
+ if (voltage === null) {
599
+ return missingQuantityComponent(component, 'voltage', warnings, null);
600
+ }
601
+ return {
602
+ ...base,
603
+ ftype: 'simple_power_source',
604
+ voltage: voltage.value.value,
605
+ display_value: voltage.value.raw,
606
+ };
607
+ }
608
+ case 'current-source': {
609
+ const current = quantity(component, 'current');
610
+ if (current === null) {
611
+ return missingQuantityComponent(component, 'current', warnings, null);
612
+ }
613
+ return {
614
+ ...base,
615
+ ftype: 'simple_current_source',
616
+ wave_shape: 'dc',
617
+ current: current.value.value,
618
+ display_value: current.value.raw,
619
+ };
620
+ }
621
+ case 'ground':
622
+ return { ...base, ftype: 'simple_ground' };
623
+ case 'jack':
624
+ return { ...base, ftype: 'simple_connector' };
625
+ case 'port':
626
+ return { ...base, ftype: 'simple_test_point' };
627
+ case 'ic':
628
+ return {
629
+ ...base,
630
+ ftype: 'simple_chip',
631
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
632
+ };
633
+ case 'ota':
634
+ case 'triode':
635
+ case 'pentode':
636
+ case 'tube-diode':
637
+ case 'transformer':
638
+ case 'optocoupler':
639
+ case 'bbd':
640
+ case 'delay-ic':
641
+ case 'power-amp':
642
+ case 'regulator':
643
+ case 'analog-switch':
644
+ case 'flipflop':
645
+ case 'label':
646
+ case 'named-wire':
647
+ warnings.push(`${component.id} (${component.kind}): no Circuit JSON source-component mapping; skipped`);
648
+ return null;
649
+ }
650
+ }
651
+
652
+ function sourceComponentBase(component: Component): CircuitJsonSourceComponent {
653
+ return {
654
+ type: 'source_component',
655
+ source_component_id: sourceComponentId(component.id),
656
+ name: component.name,
657
+ display_name: component.name,
658
+ };
659
+ }
660
+
661
+ function sourcePortElement(component: Component, terminalName: string): CircuitJsonSourcePort {
662
+ const voltage = component.kind === 'rail' ? quantity(component, 'voltage') : null;
663
+ return {
664
+ type: 'source_port',
665
+ source_port_id: sourcePortId(component.id, terminalName),
666
+ source_component_id: sourceComponentId(component.id),
667
+ name: terminalName,
668
+ port_hints: [terminalName],
669
+ ...(component.kind === 'ground' ? { provides_ground: true } : {}),
670
+ ...(component.kind === 'rail' ? { provides_power: true } : {}),
671
+ ...(voltage !== null ? { provides_voltage: voltage.value.value } : {}),
672
+ };
673
+ }
674
+
675
+ function sourceTraceElements(
676
+ connectivity: Connectivity,
677
+ names: ReadonlyMap<NodeId, string>,
678
+ sourcePortIdsByNode: ReadonlyMap<NodeId, readonly string[]>,
679
+ warnings: string[],
680
+ ): readonly CircuitJsonSourceTrace[] {
681
+ const traces: CircuitJsonSourceTrace[] = [];
682
+
683
+ for (let nodeId = 0; nodeId < connectivity.nodeCount; nodeId += 1) {
684
+ const sourcePortIds = sourcePortIdsByNode.get(nodeId) ?? [];
685
+ if (sourcePortIds.length === 0) {
686
+ warnings.push(`${sourceNetId(nodeId)}: no exported source ports reference this resolved node`);
687
+ continue;
688
+ }
689
+ traces.push({
690
+ type: 'source_trace',
691
+ source_trace_id: sourceTraceId(nodeId),
692
+ connected_source_port_ids: [...sourcePortIds],
693
+ connected_source_net_ids: [sourceNetId(nodeId)],
694
+ display_name: names.get(nodeId) ?? (connectivity.groundNodeId === nodeId ? 'GND' : `N${nodeId}`),
695
+ });
696
+ }
697
+
698
+ return traces;
699
+ }
700
+
701
+ function schematicElements(
702
+ doc: CircuitDocument,
703
+ sourceTraces: readonly CircuitJsonSourceTrace[],
704
+ ): AnyCircuitElement[] {
705
+ const elements: AnyCircuitElement[] = [];
706
+ const schematicPortBySourcePortId = new Map<string, string>();
707
+
708
+ for (const component of doc.components) {
709
+ if (SOURCE_ONLY_NET_NAME_KINDS.has(component.kind)) {
710
+ continue;
711
+ }
712
+ const schematicComponentId = `schematic_component:${component.id}`;
713
+ elements.push({
714
+ type: 'schematic_component',
715
+ schematic_component_id: schematicComponentId,
716
+ source_component_id: sourceComponentId(component.id),
717
+ center: toTscircuitSchematicPoint(component.origin),
718
+ size: schematicComponentSize(component),
719
+ symbol_name: schematicSymbolName(component),
720
+ ...(schematicDisplayValue(component) === undefined ? {} : { symbol_display_value: schematicDisplayValue(component) }),
721
+ is_box_with_pins: true,
722
+ });
723
+ for (const [terminalIndex, terminal] of component.terminals.entries()) {
724
+ const sourcePortIdValue = sourcePortId(component.id, terminal.name);
725
+ const schematicPortId = `schematic_port:${component.id}:${terminal.name}`;
726
+ schematicPortBySourcePortId.set(sourcePortIdValue, schematicPortId);
727
+ elements.push({
728
+ type: 'schematic_port',
729
+ schematic_port_id: schematicPortId,
730
+ source_port_id: sourcePortIdValue,
731
+ schematic_component_id: schematicComponentId,
732
+ center: toTscircuitSchematicPoint(terminal.position),
733
+ is_connected: true,
734
+ display_pin_label: terminal.name,
735
+ facing_direction: schematicPortFacingDirection(component.origin, terminal.position),
736
+ distance_from_component_edge: 0.4,
737
+ pin_number: terminalIndex + 1,
738
+ });
739
+ }
740
+ }
741
+
742
+ for (const trace of sourceTraces) {
743
+ const sourcePortIds = trace.connected_source_port_ids;
744
+ if (sourcePortIds.length < 2) {
745
+ continue;
746
+ }
747
+ const first = sourcePortIds[0];
748
+ if (first === undefined) {
749
+ continue;
750
+ }
751
+ const edges = sourcePortIds.slice(1).map((sourcePortIdValue) => ({
752
+ from: sourcePortPosition(doc, first),
753
+ to: sourcePortPosition(doc, sourcePortIdValue),
754
+ from_schematic_port_id: schematicPortBySourcePortId.get(first),
755
+ to_schematic_port_id: schematicPortBySourcePortId.get(sourcePortIdValue),
756
+ }));
757
+ elements.push({
758
+ type: 'schematic_trace',
759
+ schematic_trace_id: `schematic_${trace.source_trace_id}`,
760
+ source_trace_id: trace.source_trace_id,
761
+ junctions: [],
762
+ edges,
763
+ });
764
+ }
765
+
766
+ for (const [index, directive] of doc.directives.entries()) {
767
+ elements.push({
768
+ type: 'schematic_text',
769
+ schematic_text_id: `schematic_text:directive:${index + 1}`,
770
+ text: `!${directive}`,
771
+ font_size: 0.6,
772
+ position: toTscircuitSchematicPoint({ x: 0, y: 40 + index * 12 }),
773
+ rotation: 0,
774
+ color: '#000000',
775
+ anchor: 'left',
776
+ });
777
+ }
778
+
779
+ return elements;
780
+ }
781
+
782
+ function normalizeCircuitJsonElements(elements: readonly unknown[]): CircuitJson {
783
+ const result = validateCircuitJsonDocument(elements);
784
+ if (!result.valid) {
785
+ throw new Error(`generated invalid Circuit JSON: ${result.errors.map((error) => `${error.path ?? '<root>'}: ${error.message}`).join('; ')}`);
786
+ }
787
+ return result.elements;
788
+ }
789
+
790
+ function schematicSymbolName(component: Component): string {
791
+ switch (component.kind) {
792
+ case 'resistor':
793
+ case 'variable-resistor':
794
+ return 'boxresistor_right';
795
+ case 'capacitor':
796
+ return isPolarizedCapacitor(component) ? 'capacitor_polarized_right' : 'capacitor_right';
797
+ case 'inductor':
798
+ return 'inductor_right';
799
+ case 'diode':
800
+ return diodeSymbolName(component);
801
+ case 'led':
802
+ return 'led_right';
803
+ case 'bjt':
804
+ return inferTransistorType(component) === 'pnp' ? 'pnp_bipolar_transistor_right' : 'npn_bipolar_transistor_right';
805
+ case 'jfet':
806
+ return inferJfetChannel(component) === 'p' ? 'pjfet_transistor_horz' : 'njfet_transistor_horz';
807
+ case 'mosfet':
808
+ return mosfetSymbolName(component);
809
+ case 'opamp':
810
+ return 'opamp_no_power_right';
811
+ case 'potentiometer':
812
+ return 'potentiometer2_right';
813
+ case 'switch':
814
+ return 'spst_switch_right';
815
+ case 'ground':
816
+ return 'ground_down';
817
+ case 'rail':
818
+ return 'vcc_down';
819
+ case 'voltage-source':
820
+ case 'battery':
821
+ return 'battery_vert';
822
+ case 'current-source':
823
+ return 'current_source_right';
824
+ case 'port':
825
+ return 'testpoint_right';
826
+ case 'jack':
827
+ case 'ic':
828
+ case 'ota':
829
+ case 'triode':
830
+ case 'pentode':
831
+ case 'tube-diode':
832
+ case 'transformer':
833
+ case 'optocoupler':
834
+ case 'bbd':
835
+ case 'delay-ic':
836
+ case 'power-amp':
837
+ case 'regulator':
838
+ case 'analog-switch':
839
+ case 'flipflop':
840
+ case 'unsupported':
841
+ case 'label':
842
+ case 'named-wire':
843
+ return 'testpoint_right';
844
+ }
845
+ }
846
+
847
+ function schematicComponentSize(component: Component): { readonly width: number; readonly height: number } {
848
+ switch (component.kind) {
849
+ case 'resistor':
850
+ case 'variable-resistor':
851
+ return { width: 1.1, height: 0.39 };
852
+ case 'capacitor':
853
+ return { width: 1.1, height: 0.84 };
854
+ case 'inductor':
855
+ return { width: 1.16, height: 0.46 };
856
+ case 'diode':
857
+ return { width: 1.04, height: 0.54 };
858
+ case 'led':
859
+ return { width: 1.13, height: 0.65 };
860
+ case 'bjt':
861
+ return { width: 1.1, height: inferTransistorType(component) === 'pnp' ? 0.83 : 0.95 };
862
+ case 'opamp':
863
+ return { width: 1, height: 0.72 };
864
+ case 'potentiometer':
865
+ return { width: 1.18, height: 0.58 };
866
+ case 'ground':
867
+ case 'rail':
868
+ case 'port':
869
+ case 'jack':
870
+ return { width: 0.8, height: 0.6 };
871
+ default:
872
+ return DEFAULT_SCHEMATIC_COMPONENT_SIZE;
873
+ }
874
+ }
875
+
876
+ function schematicDisplayValue(component: Component): string | undefined {
877
+ const value = quantity(component, 'resistance') ?? quantity(component, 'capacitance') ?? quantity(component, 'inductance') ?? quantity(component, 'voltage') ?? quantity(component, 'current');
878
+ return value?.value.raw;
879
+ }
880
+
881
+ function schematicPortFacingDirection(origin: Point, terminalPosition: Point): CircuitJsonSchematicPortDirection {
882
+ const dx = terminalPosition.x - origin.x;
883
+ const dy = terminalPosition.y - origin.y;
884
+ if (Math.abs(dx) >= Math.abs(dy)) {
885
+ return dx < 0 ? 'left' : 'right';
886
+ }
887
+ return dy < 0 ? 'up' : 'down';
888
+ }
889
+
890
+ function isPolarizedCapacitor(component: Component): boolean {
891
+ const text = searchablePropertyText(component);
892
+ return text.includes('electrolytic') || text.includes('polar');
893
+ }
894
+
895
+ function diodeSymbolName(component: Component): string {
896
+ const text = searchablePropertyText(component);
897
+ if (text.includes('zener')) {
898
+ return 'zener_diode_horz';
899
+ }
900
+ if (text.includes('schottky')) {
901
+ return 'schottky_diode_right';
902
+ }
903
+ return 'diode_right';
904
+ }
905
+
906
+ function mosfetSymbolName(component: Component): string {
907
+ const channel = inferMosfetChannel(component);
908
+ const mode = inferMosfetMode(component);
909
+ if (channel === 'p') {
910
+ return mode === 'depletion' ? 'p_channel_d_mosfet_transistor_horz' : 'p_channel_e_mosfet_transistor_horz';
911
+ }
912
+ return mode === 'depletion' ? 'n_channel_d_mosfet_transistor_horz' : 'n_channel_e_mosfet_transistor_horz';
913
+ }
914
+
915
+ function netNames(doc: CircuitDocument, connectivity: Connectivity): ReadonlyMap<NodeId, string> {
916
+ const names = new Map<NodeId, string>();
917
+ for (const component of doc.components) {
918
+ if (component.terminals.length === 0) {
919
+ continue;
920
+ }
921
+ if (component.kind !== 'rail' && component.kind !== 'named-wire' && component.kind !== 'label') {
922
+ continue;
923
+ }
924
+ const terminal = component.terminals[0];
925
+ if (terminal === undefined) {
926
+ continue;
927
+ }
928
+ const nodeId = getPinNode(connectivity, {
929
+ componentId: component.id,
930
+ terminalName: terminal.name,
931
+ });
932
+ if (nodeId !== undefined && !names.has(nodeId)) {
933
+ names.set(nodeId, component.name);
934
+ }
935
+ }
936
+ if (connectivity.groundNodeId !== null) {
937
+ names.set(connectivity.groundNodeId, 'GND');
938
+ }
939
+ return names;
940
+ }
941
+
942
+ function railNodeIds(doc: CircuitDocument, connectivity: Connectivity): ReadonlySet<NodeId> {
943
+ const ids = new Set<NodeId>();
944
+ for (const component of doc.components) {
945
+ if (component.kind !== 'rail') {
946
+ continue;
947
+ }
948
+ for (const terminal of component.terminals) {
949
+ const nodeId = getPinNode(connectivity, {
950
+ componentId: component.id,
951
+ terminalName: terminal.name,
952
+ });
953
+ if (nodeId !== undefined) {
954
+ ids.add(nodeId);
955
+ }
956
+ }
957
+ }
958
+ return ids;
959
+ }
960
+
961
+ function sourceOnlyWarnings(
962
+ doc: CircuitDocument,
963
+ exportedComponentIds: ReadonlySet<string>,
964
+ ): readonly string[] {
965
+ const warnings: string[] = [];
966
+ for (const component of doc.components) {
967
+ if (exportedComponentIds.has(component.id)) {
968
+ continue;
969
+ }
970
+ if (SOURCE_ONLY_NET_NAME_KINDS.has(component.kind)) {
971
+ warnings.push(`${component.id} (${component.kind}): used for net naming only; no Circuit JSON source component emitted`);
972
+ }
973
+ }
974
+ return warnings;
975
+ }
976
+
977
+ function appendSourcePort(map: Map<NodeId, string[]>, nodeId: NodeId, sourcePortIdValue: string): void {
978
+ const existing = map.get(nodeId);
979
+ if (existing === undefined) {
980
+ map.set(nodeId, [sourcePortIdValue]);
981
+ return;
982
+ }
983
+ existing.push(sourcePortIdValue);
984
+ }
985
+
986
+ function quantity(component: Component, key: QuantityKey): QuantityLookup | null {
987
+ const names = VALUE_PROPERTY_NAMES[key];
988
+ for (const name of names) {
989
+ const value = component.properties[name];
990
+ const parsed = propertyQuantity(value);
991
+ if (parsed !== null) {
992
+ return { value: parsed };
993
+ }
994
+ }
995
+ return null;
996
+ }
997
+
998
+ function propertyQuantity(value: PropertyValue | undefined): ParsedQuantity | null {
999
+ return propertyQuantityValue(value);
1000
+ }
1001
+
1002
+ function missingQuantityComponent(
1003
+ component: Component,
1004
+ quantityName: string,
1005
+ warnings: string[],
1006
+ manufacturerPartNumber: string | null,
1007
+ ): CircuitJsonSourceComponent {
1008
+ warnings.push(`${component.id} (${component.kind}): missing ${quantityName}; emitted opaque simple_chip source component metadata only`);
1009
+ return {
1010
+ ...sourceComponentBase(component),
1011
+ ftype: 'simple_chip',
1012
+ ...(manufacturerPartNumber !== null ? { manufacturer_part_number: manufacturerPartNumber } : {}),
1013
+ };
1014
+ }
1015
+
1016
+ function firstStringProperty(component: Component, names: readonly string[]): string | null {
1017
+ for (const name of names) {
1018
+ const value = component.properties[name];
1019
+ const text = propertyStringValue(value);
1020
+ if (text !== null && text.trim().length > 0) {
1021
+ return text;
1022
+ }
1023
+ }
1024
+ return null;
1025
+ }
1026
+
1027
+ function inferTransistorType(component: Component): 'npn' | 'pnp' {
1028
+ const searchable = searchablePropertyText(component);
1029
+ return searchable.includes('pnp') ? 'pnp' : 'npn';
1030
+ }
1031
+
1032
+ function inferMosfetChannel(component: Component): 'n' | 'p' {
1033
+ const searchable = searchablePropertyText(component);
1034
+ return searchable.includes('pmos') || searchable.includes('p-channel') || searchable.includes('p channel') ? 'p' : 'n';
1035
+ }
1036
+
1037
+ function inferJfetChannel(component: Component): 'n' | 'p' {
1038
+ const searchable = searchablePropertyText(component);
1039
+ return searchable.includes('pjf') || searchable.includes('p-channel') || searchable.includes('p channel') ? 'p' : 'n';
1040
+ }
1041
+
1042
+ function inferMosfetMode(component: Component): 'enhancement' | 'depletion' {
1043
+ const searchable = searchablePropertyText(component);
1044
+ return searchable.includes('depletion') ? 'depletion' : 'enhancement';
1045
+ }
1046
+
1047
+ function searchablePropertyText(component: Component): string {
1048
+ const values: string[] = [component.name, component.sourceTypeName ?? ''];
1049
+ for (const value of Object.values(component.properties)) {
1050
+ const text = propertyStringValue(value);
1051
+ if (text !== null) {
1052
+ values.push(text);
1053
+ }
1054
+ }
1055
+ return values.join(' ').toLowerCase();
1056
+ }
1057
+
1058
+ function sourceComponentId(componentId: string): string {
1059
+ return `source_component:${componentId}`;
1060
+ }
1061
+
1062
+ function sourcePortId(componentId: string, terminalName: string): string {
1063
+ return `source_port:${componentId}:${terminalName}`;
1064
+ }
1065
+
1066
+ function sourceNetId(nodeId: NodeId): string {
1067
+ return `source_net:${nodeId}`;
1068
+ }
1069
+
1070
+ function sourceTraceId(nodeId: NodeId): string {
1071
+ return `source_trace:${nodeId}`;
1072
+ }
1073
+
1074
+ function sourcePortPosition(doc: CircuitDocument, sourcePortIdValue: string): Point {
1075
+ const parsed = parseSourcePortId(sourcePortIdValue);
1076
+ if (parsed === null) {
1077
+ return { x: 0, y: 0 };
1078
+ }
1079
+ const component = doc.components.find((candidate) => candidate.id === parsed.componentId);
1080
+ const terminal = component?.terminals.find((candidate) => candidate.name === parsed.terminalName);
1081
+ return toTscircuitSchematicPoint(terminal?.position ?? component?.origin ?? { x: 0, y: 0 });
1082
+ }
1083
+
1084
+ function toTscircuitSchematicPoint(point: Point): Point {
1085
+ return {
1086
+ x: roundTscircuitSchematicCoordinate(point.x * TSCIRCUIT_SCHEMATIC_COORD_SCALE),
1087
+ y: roundTscircuitSchematicCoordinate(point.y * TSCIRCUIT_SCHEMATIC_COORD_SCALE),
1088
+ };
1089
+ }
1090
+
1091
+ function roundTscircuitSchematicCoordinate(value: number): number {
1092
+ return Math.round(value * 1000) / 1000;
1093
+ }
1094
+
1095
+ function parseSourcePortId(sourcePortIdValue: string): Readonly<{ componentId: string; terminalName: string }> | null {
1096
+ const prefix = 'source_port:';
1097
+ if (!sourcePortIdValue.startsWith(prefix)) {
1098
+ return null;
1099
+ }
1100
+ const rest = sourcePortIdValue.slice(prefix.length);
1101
+ const separator = rest.lastIndexOf(':');
1102
+ if (separator < 0) {
1103
+ return null;
1104
+ }
1105
+ return {
1106
+ componentId: rest.slice(0, separator),
1107
+ terminalName: rest.slice(separator + 1),
1108
+ };
1109
+ }
1110
+
1111
+ function summarizeSchemaIssues(
1112
+ issues: readonly {
1113
+ readonly path: readonly (string | number)[];
1114
+ readonly message: string;
1115
+ }[],
1116
+ ): string {
1117
+ return issues
1118
+ .slice(0, 3)
1119
+ .map((issue) => {
1120
+ const path = issue.path.length === 0 ? '<root>' : issue.path.join('.');
1121
+ return `${path}: ${issue.message}`;
1122
+ })
1123
+ .join('; ');
1124
+ }
1125
+
1126
+ function shallowSchemaIssue(value: unknown): string | null {
1127
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1128
+ return 'Circuit JSON element must be an object';
1129
+ }
1130
+ const record = Object.fromEntries(Object.entries(value));
1131
+ const type = record.type;
1132
+ if (type === 'source_component' && typeof record.source_component_id !== 'string') {
1133
+ return 'source_component_id: Expected string';
1134
+ }
1135
+ return null;
1136
+ }
1137
+
1138
+ function checkedRecord(value: unknown): JsonRecord {
1139
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1140
+ throw new Error('expected Circuit JSON element object');
1141
+ }
1142
+ return Object.fromEntries(Object.entries(value));
1143
+ }
1144
+
1145
+ function stringField(record: JsonRecord, key: string): string | null {
1146
+ const value = record[key];
1147
+ return typeof value === 'string' ? value : null;
1148
+ }
1149
+
1150
+ function requiredStringField(record: JsonRecord, key: string): string {
1151
+ const value = stringField(record, key);
1152
+ if (value === null) {
1153
+ throw new Error(`Circuit JSON element is missing string field ${key}`);
1154
+ }
1155
+ return value;
1156
+ }
1157
+
1158
+ function numericField(record: JsonRecord, key: string): number | null {
1159
+ const value = record[key];
1160
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
1161
+ }
1162
+
1163
+ function pointField(record: JsonRecord, key: string): Point | null {
1164
+ const value = record[key];
1165
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1166
+ return null;
1167
+ }
1168
+ const pointRecord = checkedRecord(value);
1169
+ const x = pointRecord.x;
1170
+ const y = pointRecord.y;
1171
+ return typeof x === 'number' && typeof y === 'number' ? { x, y } : null;
1172
+ }
1173
+
1174
+ function stringArrayField(record: JsonRecord, key: string): readonly string[] {
1175
+ const value = record[key];
1176
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
1177
+ }
1178
+
1179
+ function stripKnownPrefix(value: string, prefix: string): string | null {
1180
+ return value.startsWith(prefix) ? value.slice(prefix.length) : null;
1181
+ }
1182
+
1183
+ function sanitizeId(value: string): string {
1184
+ const sanitized = value.replace(/\s+/g, '-').replace(/[^A-Za-z0-9_-]/g, '');
1185
+ return sanitized.length > 0 ? sanitized : 'component';
1186
+ }
1187
+
1188
+ function terminalNameFromSourcePortId(sourcePortIdValue: string): string {
1189
+ const parsed = parseSourcePortId(sourcePortIdValue);
1190
+ return parsed?.terminalName ?? sanitizeId(sourcePortIdValue);
1191
+ }
1192
+
1193
+ function groupPortsByComponent(
1194
+ sourcePorts: ReadonlyMap<string, SourcePortRecord>,
1195
+ ): ReadonlyMap<string, readonly SourcePortRecord[]> {
1196
+ const map = new Map<string, SourcePortRecord[]>();
1197
+ for (const port of sourcePorts.values()) {
1198
+ const existing = map.get(port.componentSourceId);
1199
+ if (existing === undefined) {
1200
+ map.set(port.componentSourceId, [port]);
1201
+ continue;
1202
+ }
1203
+ existing.push(port);
1204
+ }
1205
+ return map;
1206
+ }
1207
+
1208
+ function buildComponentFromCircuitJson(
1209
+ sourceComponent: SourceComponentRecord,
1210
+ ports: readonly SourcePortRecord[],
1211
+ schematicComponents: ReadonlyMap<string, SchematicComponentRecord>,
1212
+ schematicPorts: ReadonlyMap<string, SchematicPortRecord>,
1213
+ index: number,
1214
+ ): MutableComponentBuild & { readonly component: Component } {
1215
+ const origin = schematicComponents.get(sourceComponent.sourceComponentId)?.center ?? synthesizedOrigin(index);
1216
+ const terminals = ports.map((port, portIndex) => ({
1217
+ name: port.terminalName,
1218
+ position: schematicPorts.get(port.sourcePortId)?.center ?? synthesizedTerminalPosition(origin, ports.length, portIndex),
1219
+ }));
1220
+ const component: Component = {
1221
+ id: sourceComponent.componentId,
1222
+ kind: kindFromCircuitJsonFtype(sourceComponent.ftype),
1223
+ name: sourceComponent.name,
1224
+ origin,
1225
+ rotation: 0,
1226
+ flipped: false,
1227
+ terminals,
1228
+ properties: propertiesFromCircuitJsonComponent(sourceComponent.record),
1229
+ sourceTypeName: sourceComponent.ftype === null ? 'circuit-json:source_component' : `circuit-json:${sourceComponent.ftype}`,
1230
+ };
1231
+ return { sourceComponent, ports, origin, terminals, component };
1232
+ }
1233
+
1234
+ function synthesizedOrigin(index: number): Point {
1235
+ const column = index % 4;
1236
+ const row = Math.floor(index / 4);
1237
+ return { x: column * 80, y: row * 60 };
1238
+ }
1239
+
1240
+ function synthesizedTerminalPosition(origin: Point, terminalCount: number, index: number): Point {
1241
+ if (terminalCount <= 1) {
1242
+ return origin;
1243
+ }
1244
+ if (terminalCount === 2) {
1245
+ return index === 0
1246
+ ? { x: origin.x - 20, y: origin.y }
1247
+ : { x: origin.x + 20, y: origin.y };
1248
+ }
1249
+ const step = terminalCount === 1 ? 0 : 40 / (terminalCount - 1);
1250
+ return { x: origin.x - 20, y: origin.y - 20 + step * index };
1251
+ }
1252
+
1253
+ function kindFromCircuitJsonFtype(ftype: string | null): ComponentKind {
1254
+ switch (ftype) {
1255
+ case 'simple_resistor':
1256
+ return 'resistor';
1257
+ case 'simple_capacitor':
1258
+ return 'capacitor';
1259
+ case 'simple_inductor':
1260
+ return 'inductor';
1261
+ case 'simple_diode':
1262
+ return 'diode';
1263
+ case 'simple_led':
1264
+ return 'led';
1265
+ case 'simple_transistor':
1266
+ return 'bjt';
1267
+ case 'simple_mosfet':
1268
+ return 'mosfet';
1269
+ case 'simple_op_amp':
1270
+ return 'opamp';
1271
+ case 'simple_potentiometer':
1272
+ return 'potentiometer';
1273
+ case 'simple_switch':
1274
+ case 'simple_push_button':
1275
+ return 'switch';
1276
+ case 'simple_voltage_source':
1277
+ return 'voltage-source';
1278
+ case 'simple_current_source':
1279
+ return 'current-source';
1280
+ case 'simple_battery':
1281
+ return 'battery';
1282
+ case 'simple_ground':
1283
+ return 'ground';
1284
+ case 'simple_power_source':
1285
+ return 'rail';
1286
+ case 'simple_connector':
1287
+ return 'jack';
1288
+ case 'simple_test_point':
1289
+ return 'port';
1290
+ case 'simple_chip':
1291
+ return 'ic';
1292
+ default:
1293
+ return 'unsupported';
1294
+ }
1295
+ }
1296
+
1297
+ function propertiesFromCircuitJsonComponent(record: JsonRecord): Readonly<Record<string, PropertyValue>> {
1298
+ const properties: Record<string, PropertyValue> = {};
1299
+ const ftype = stringField(record, 'ftype');
1300
+ const displayValue = stringField(record, 'display_value');
1301
+ if (displayValue !== null) {
1302
+ properties.Value = displayValue;
1303
+ }
1304
+ const manufacturerPartNumber = stringField(record, 'manufacturer_part_number');
1305
+ if (manufacturerPartNumber !== null) {
1306
+ properties.manufacturerPartNumber = manufacturerPartNumber;
1307
+ }
1308
+ addQuantityProperty(properties, 'R', record, 'display_resistance', 'resistance');
1309
+ addQuantityProperty(properties, 'C', record, 'display_capacitance', 'capacitance');
1310
+ addQuantityProperty(properties, 'L', record, 'display_inductance', 'inductance');
1311
+ addQuantityProperty(properties, 'V', record, null, 'voltage');
1312
+ addQuantityProperty(properties, 'I', record, null, 'current');
1313
+ addQuantityProperty(properties, 'Resistance', record, 'display_max_resistance', 'max_resistance');
1314
+ if (ftype !== null) {
1315
+ properties.ftype = ftype;
1316
+ }
1317
+ return properties;
1318
+ }
1319
+
1320
+ function addQuantityProperty(
1321
+ properties: Record<string, PropertyValue>,
1322
+ propertyName: string,
1323
+ record: JsonRecord,
1324
+ displayKey: string | null,
1325
+ numericKey: string,
1326
+ ): void {
1327
+ const display = displayKey === null ? null : stringField(record, displayKey);
1328
+ if (display !== null) {
1329
+ properties[propertyName] = parseQuantity(display) ?? display;
1330
+ return;
1331
+ }
1332
+ const numeric = numericField(record, numericKey);
1333
+ if (numeric !== null) {
1334
+ properties[propertyName] = numeric;
1335
+ }
1336
+ }
1337
+
1338
+ function terminalPositionMap(
1339
+ builds: readonly (MutableComponentBuild & { readonly component: Component })[],
1340
+ ): ReadonlyMap<string, Point> {
1341
+ const map = new Map<string, Point>();
1342
+ for (const build of builds) {
1343
+ for (const port of build.ports) {
1344
+ const terminal = build.component.terminals.find((candidate) => candidate.name === port.terminalName);
1345
+ if (terminal !== undefined) {
1346
+ map.set(port.sourcePortId, terminal.position);
1347
+ }
1348
+ }
1349
+ }
1350
+ return map;
1351
+ }
1352
+
1353
+ function wireElementsFromSourceTraces(
1354
+ sourceTraces: readonly JsonRecord[],
1355
+ terminalPositions: ReadonlyMap<string, Point>,
1356
+ ): readonly Wire[] {
1357
+ const wires: Wire[] = [];
1358
+ for (const trace of sourceTraces) {
1359
+ const ports = stringArrayField(trace, 'connected_source_port_ids');
1360
+ if (ports.length < 2) {
1361
+ continue;
1362
+ }
1363
+ const firstPort = ports[0];
1364
+ const firstPosition = firstPort === undefined ? undefined : terminalPositions.get(firstPort);
1365
+ if (firstPosition === undefined) {
1366
+ continue;
1367
+ }
1368
+ for (const port of ports.slice(1)) {
1369
+ const position = terminalPositions.get(port);
1370
+ if (position === undefined) {
1371
+ continue;
1372
+ }
1373
+ wires.push({
1374
+ id: `wire-${wires.length + 1}`,
1375
+ endpoints: [firstPosition, position],
1376
+ });
1377
+ }
1378
+ }
1379
+ return wires;
1380
+ }
1381
+
1382
+ function warningsForUnconnectedNets(
1383
+ sourceNets: ReadonlyMap<string, JsonRecord>,
1384
+ sourceTraces: readonly JsonRecord[],
1385
+ ): readonly Warning[] {
1386
+ const referencedNetIds = new Set<string>();
1387
+ for (const trace of sourceTraces) {
1388
+ for (const netId of stringArrayField(trace, 'connected_source_net_ids')) {
1389
+ referencedNetIds.add(netId);
1390
+ }
1391
+ }
1392
+ const warnings: Warning[] = [];
1393
+ for (const [netId, net] of sourceNets) {
1394
+ if (!referencedNetIds.has(netId)) {
1395
+ warnings.push({
1396
+ code: 'circuit-json-net-unconnected',
1397
+ message: `${stringField(net, 'name') ?? netId}: Circuit JSON source net is not referenced by a source trace`,
1398
+ });
1399
+ }
1400
+ }
1401
+ return warnings;
1402
+ }
1403
+
1404
+ function filenameWithoutCircuitJsonExtension(filename: string): string {
1405
+ return filename
1406
+ .replace(/\.circuit\.json$/i, '')
1407
+ .replace(/\.json$/i, '')
1408
+ .replace(/[-_]+/g, ' ')
1409
+ .trim();
1410
+ }