@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.
- package/LICENSE.md +21 -0
- package/README.md +6 -0
- package/package.json +56 -0
- package/src/editor/commands.ts +344 -0
- package/src/editor/factory.ts +148 -0
- package/src/editor/history.ts +142 -0
- package/src/editor/index.ts +11 -0
- package/src/editor/layout.ts +207 -0
- package/src/formats/circuit-json/serializer.ts +1410 -0
- package/src/formats/document.ts +274 -0
- package/src/formats/interchange/parser.ts +1165 -0
- package/src/formats/interchange/serializer.ts +594 -0
- package/src/formats/ltspice/catalog.ts +181 -0
- package/src/formats/ltspice/encoding.ts +151 -0
- package/src/formats/ltspice/parser.ts +432 -0
- package/src/formats/ltspice/serializer.ts +169 -0
- package/src/formats/schx/catalog.ts +439 -0
- package/src/formats/schx/parser.ts +261 -0
- package/src/formats/schx/runtime-descriptors.ts +502 -0
- package/src/formats/schx/serializer.ts +211 -0
- package/src/formats/schx/transforms.ts +38 -0
- package/src/formats/spice/parser.ts +373 -0
- package/src/formats/spice/serializer.ts +43 -0
- package/src/index.ts +205 -0
- package/src/model/connectivity.ts +239 -0
- package/src/model/netlist.ts +375 -0
- package/src/model/properties.ts +101 -0
- package/src/model/quantity.ts +173 -0
- package/src/model/types.ts +309 -0
- package/src/model/validation.ts +985 -0
- package/src/model/wires.ts +86 -0
- package/src/panel/extract.ts +878 -0
- package/src/panel/index.ts +39 -0
- package/src/panel/knobs.ts +70 -0
- package/src/panel/protocol.ts +117 -0
- package/src/panel/types.ts +180 -0
- package/src/preview/bounds.ts +85 -0
- package/src/preview/box-layout.ts +24 -0
- package/src/preview/colors.ts +43 -0
- package/src/preview/hanging.ts +94 -0
- package/src/preview/junctions.ts +94 -0
- package/src/preview/label-layout.ts +90 -0
- package/src/preview/ports.ts +101 -0
- package/src/preview/renderable-wires.ts +113 -0
- package/src/preview/routing.ts +15 -0
- package/src/preview/snap.ts +104 -0
- package/src/preview/symbols/analog-switch.svg +17 -0
- package/src/preview/symbols/battery.svg +16 -0
- package/src/preview/symbols/bbd.svg +21 -0
- package/src/preview/symbols/bjt-npn.svg +16 -0
- package/src/preview/symbols/bjt-pnp.svg +17 -0
- package/src/preview/symbols/capacitor-electrolytic.svg +13 -0
- package/src/preview/symbols/capacitor.svg +12 -0
- package/src/preview/symbols/current-source.svg +14 -0
- package/src/preview/symbols/delay-ic.svg +22 -0
- package/src/preview/symbols/diode-schottky.svg +12 -0
- package/src/preview/symbols/diode-zener.svg +12 -0
- package/src/preview/symbols/diode.svg +13 -0
- package/src/preview/symbols/flipflop.svg +20 -0
- package/src/preview/symbols/ground.svg +12 -0
- package/src/preview/symbols/ic-block.svg +20 -0
- package/src/preview/symbols/ic.svg +19 -0
- package/src/preview/symbols/inductor.svg +11 -0
- package/src/preview/symbols/jack-input.svg +16 -0
- package/src/preview/symbols/jack-output.svg +16 -0
- package/src/preview/symbols/jfet-junction-n.svg +17 -0
- package/src/preview/symbols/jfet-n.svg +17 -0
- package/src/preview/symbols/jfet-p.svg +17 -0
- package/src/preview/symbols/label.svg +8 -0
- package/src/preview/symbols/led.svg +18 -0
- package/src/preview/symbols/mosfet-n.svg +21 -0
- package/src/preview/symbols/mosfet-p.svg +21 -0
- package/src/preview/symbols/named-wire.svg +11 -0
- package/src/preview/symbols/opamp.svg +21 -0
- package/src/preview/symbols/optocoupler.svg +30 -0
- package/src/preview/symbols/ota.svg +20 -0
- package/src/preview/symbols/pentode.svg +25 -0
- package/src/preview/symbols/photoresistor.svg +19 -0
- package/src/preview/symbols/port.svg +8 -0
- package/src/preview/symbols/potentiometer.svg +15 -0
- package/src/preview/symbols/power-amp.svg +20 -0
- package/src/preview/symbols/rail.svg +11 -0
- package/src/preview/symbols/regulator.svg +13 -0
- package/src/preview/symbols/relay.svg +20 -0
- package/src/preview/symbols/resistor.svg +11 -0
- package/src/preview/symbols/svg-content.ts +59 -0
- package/src/preview/symbols/switch-3pdt.svg +32 -0
- package/src/preview/symbols/switch-rotary.svg +23 -0
- package/src/preview/symbols/switch-spdt.svg +16 -0
- package/src/preview/symbols/switch-spst.svg +14 -0
- package/src/preview/symbols/switch-toggle.svg +14 -0
- package/src/preview/symbols/transformer.svg +17 -0
- package/src/preview/symbols/triode.svg +17 -0
- package/src/preview/symbols/tube-diode.svg +13 -0
- package/src/preview/symbols/unsupported.svg +8 -0
- package/src/preview/symbols/variable-resistor.svg +13 -0
- package/src/preview/symbols/voltage-source.svg +15 -0
- package/src/preview/symbols.ts +207 -0
- package/src/preview/wire-chains.ts +153 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { propertyValueForSourceAttribute } from '../../model/properties';
|
|
2
|
+
import type { CircuitDocument, Component, Point, PropertyValue, Wire } from '../../model/types';
|
|
3
|
+
import {
|
|
4
|
+
defaultDefForKind,
|
|
5
|
+
fullSchxType,
|
|
6
|
+
SCHX_SYMBOL_ELEMENT_TYPE,
|
|
7
|
+
SCHX_WIRE_ELEMENT_TYPE,
|
|
8
|
+
shortenSchxType,
|
|
9
|
+
} from './catalog';
|
|
10
|
+
|
|
11
|
+
export function serializeSchx(doc: CircuitDocument): string {
|
|
12
|
+
const lines: string[] = [];
|
|
13
|
+
lines.push('<?xml version="1.0" encoding="utf-8"?>');
|
|
14
|
+
lines.push(`<Schematic ${formatAttrs(rootAttributes(doc))}>`);
|
|
15
|
+
|
|
16
|
+
for (const component of doc.components) {
|
|
17
|
+
lines.push(` <Element ${formatAttrs(elementAttributes(component))}>`);
|
|
18
|
+
lines.push(` <Component ${formatAttrs(componentAttributes(component))} />`);
|
|
19
|
+
lines.push(' </Element>');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const wire of doc.wires) {
|
|
23
|
+
lines.push(` <Element ${formatAttrs(wireAttributes(wire))} />`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
lines.push('</Schematic>');
|
|
27
|
+
return `${lines.join('\n')}\n`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rootAttributes(doc: CircuitDocument): Record<string, string> {
|
|
31
|
+
const attrs: Record<string, string> = {
|
|
32
|
+
Name: doc.metadata.name,
|
|
33
|
+
Description: doc.metadata.description,
|
|
34
|
+
PartNumber: doc.metadata.partNumber,
|
|
35
|
+
};
|
|
36
|
+
for (const [key, value] of Object.entries(doc.rawAttributes)) {
|
|
37
|
+
if (key === 'Name' || key === 'Description' || key === 'PartNumber') {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
attrs[key] = value;
|
|
41
|
+
}
|
|
42
|
+
return attrs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function elementAttributes(component: Component): Record<string, string> {
|
|
46
|
+
return {
|
|
47
|
+
Type: SCHX_SYMBOL_ELEMENT_TYPE,
|
|
48
|
+
Rotation: String(component.rotation),
|
|
49
|
+
Flip: component.flipped ? 'true' : 'false',
|
|
50
|
+
Position: pointToString(component.origin),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function componentAttributes(component: Component): Record<string, string> {
|
|
55
|
+
const type = component.sourceTypeName ?? guessSourceType(component);
|
|
56
|
+
const attrs: Record<string, string> = { _Type: type };
|
|
57
|
+
const skipDerivedDescriptorKeys = component.properties.RuntimeDescriptor === 'true';
|
|
58
|
+
|
|
59
|
+
for (const [key, value] of Object.entries(component.properties)) {
|
|
60
|
+
if (skipDerivedDescriptorKeys && DERIVED_RUNTIME_DESCRIPTOR_KEYS.has(key)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const serialized = stringifyPropertyValue(value);
|
|
64
|
+
if (serialized !== null) {
|
|
65
|
+
attrs[key] = serialized;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (component.kind === 'led' && attrs.Type === undefined) {
|
|
70
|
+
attrs.Type = 'LED';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
attrs.Name = component.name;
|
|
74
|
+
return attrs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function wireAttributes(wire: Wire): Record<string, string> {
|
|
78
|
+
return {
|
|
79
|
+
Type: SCHX_WIRE_ELEMENT_TYPE,
|
|
80
|
+
A: pointToString(wire.endpoints[0]),
|
|
81
|
+
B: pointToString(wire.endpoints[1]),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function guessSourceType(component: Component): string {
|
|
86
|
+
if (component.kind === 'led') {
|
|
87
|
+
return fullSchxType('Diode');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const def = defaultDefForKind(component.kind);
|
|
91
|
+
if (def === undefined) {
|
|
92
|
+
return fullSchxType('Unknown');
|
|
93
|
+
}
|
|
94
|
+
const shortName = component.kind === 'tube-diode' ? 'TubeDiode' : def.shortType;
|
|
95
|
+
return fullSchxType(shortName);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stringifyPropertyValue(value: PropertyValue): string | null {
|
|
99
|
+
return propertyValueForSourceAttribute(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const DERIVED_RUNTIME_DESCRIPTOR_KEYS: ReadonlySet<string> = new Set([
|
|
103
|
+
'DescriptorType',
|
|
104
|
+
'mechanism',
|
|
105
|
+
'algorithm',
|
|
106
|
+
'topology',
|
|
107
|
+
'descriptor',
|
|
108
|
+
'bands',
|
|
109
|
+
'sections',
|
|
110
|
+
'controlLaw',
|
|
111
|
+
'overallGainDb',
|
|
112
|
+
'maxSections',
|
|
113
|
+
'toneControlName',
|
|
114
|
+
'minToneWipe',
|
|
115
|
+
'maxToneWipe',
|
|
116
|
+
'defaultToneWipe',
|
|
117
|
+
'defaultBassWipe',
|
|
118
|
+
'defaultMiddleWipe',
|
|
119
|
+
'defaultTrebleWipe',
|
|
120
|
+
'inputGain',
|
|
121
|
+
'outputGain',
|
|
122
|
+
'minDelayMs',
|
|
123
|
+
'maxDelayMs',
|
|
124
|
+
'defaultDelayMs',
|
|
125
|
+
'feedback',
|
|
126
|
+
'minFeedback',
|
|
127
|
+
'maxFeedback',
|
|
128
|
+
'mix',
|
|
129
|
+
'minMix',
|
|
130
|
+
'maxMix',
|
|
131
|
+
'level',
|
|
132
|
+
'minOutputLevel',
|
|
133
|
+
'maxOutputLevel',
|
|
134
|
+
'tone',
|
|
135
|
+
'modRateHz',
|
|
136
|
+
'minModRateHz',
|
|
137
|
+
'maxModRateHz',
|
|
138
|
+
'modDepthMs',
|
|
139
|
+
'minModDepthMs',
|
|
140
|
+
'maxModDepthMs',
|
|
141
|
+
'inputDrive',
|
|
142
|
+
'minInputDrive',
|
|
143
|
+
'maxInputDrive',
|
|
144
|
+
'headroom',
|
|
145
|
+
'stereoOutputMode',
|
|
146
|
+
'wetOnly',
|
|
147
|
+
'dryUnity',
|
|
148
|
+
'hold',
|
|
149
|
+
'samplerRecordPlay',
|
|
150
|
+
'preDelayMs',
|
|
151
|
+
'decay',
|
|
152
|
+
'damping',
|
|
153
|
+
'size',
|
|
154
|
+
'detectorMode',
|
|
155
|
+
'sensitivity',
|
|
156
|
+
'minSensitivity',
|
|
157
|
+
'maxSensitivity',
|
|
158
|
+
'attackMs',
|
|
159
|
+
'minAttackMs',
|
|
160
|
+
'maxAttackMs',
|
|
161
|
+
'releaseMs',
|
|
162
|
+
'minReleaseMs',
|
|
163
|
+
'maxReleaseMs',
|
|
164
|
+
'ratio',
|
|
165
|
+
'thresholdDb',
|
|
166
|
+
'kneeDb',
|
|
167
|
+
'dividerMode',
|
|
168
|
+
'dividerStages',
|
|
169
|
+
'trackerCutoffHz',
|
|
170
|
+
'schmittHysteresis',
|
|
171
|
+
'gateThreshold',
|
|
172
|
+
'gateRelease',
|
|
173
|
+
'square1CutoffHz',
|
|
174
|
+
'square2CutoffHz',
|
|
175
|
+
'chopperPreCutoffHz',
|
|
176
|
+
'chopperPostCutoffHz',
|
|
177
|
+
'chopperControlCutoffHz',
|
|
178
|
+
'directLevel',
|
|
179
|
+
'oct1Level',
|
|
180
|
+
'oct2Level',
|
|
181
|
+
'toneHz',
|
|
182
|
+
'minToneHz',
|
|
183
|
+
'maxToneHz',
|
|
184
|
+
'carrierModRateHz',
|
|
185
|
+
'minCarrierModRateHz',
|
|
186
|
+
'maxCarrierModRateHz',
|
|
187
|
+
'carrierModAmount',
|
|
188
|
+
'carrierModShape',
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
function pointToString(p: Point): string {
|
|
192
|
+
return `${p.x},${p.y}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function formatAttrs(attrs: Record<string, string>): string {
|
|
196
|
+
return Object.entries(attrs)
|
|
197
|
+
.filter(([, value]) => value !== undefined)
|
|
198
|
+
.map(([key, value]) => `${key}="${escapeXml(value)}"`)
|
|
199
|
+
.join(' ');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function escapeXml(value: string): string {
|
|
203
|
+
return value
|
|
204
|
+
.replaceAll('&', '&')
|
|
205
|
+
.replaceAll('"', '"')
|
|
206
|
+
.replaceAll("'", ''')
|
|
207
|
+
.replaceAll('<', '<')
|
|
208
|
+
.replaceAll('>', '>');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { shortenSchxType };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Point, Rotation } from '../../model/types';
|
|
2
|
+
|
|
3
|
+
export function mapTerminal(local: Point, origin: Point, rotation: Rotation, flipped: boolean): Point {
|
|
4
|
+
const x = local.x;
|
|
5
|
+
const y = flipped ? local.y : -local.y;
|
|
6
|
+
const cos = quarterCos(rotation);
|
|
7
|
+
const sin = quarterSin(rotation);
|
|
8
|
+
return {
|
|
9
|
+
x: x * cos + y * sin + origin.x,
|
|
10
|
+
y: y * cos - x * sin + origin.y,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeRotation(raw: number): Rotation {
|
|
15
|
+
if (!Number.isFinite(raw)) {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
const r = (((Math.trunc(raw) % 4) + 4) % 4) as Rotation;
|
|
19
|
+
return r;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function quarterCos(r: Rotation): number {
|
|
23
|
+
switch (r) {
|
|
24
|
+
case 0: return 1;
|
|
25
|
+
case 1: return 0;
|
|
26
|
+
case 2: return -1;
|
|
27
|
+
case 3: return 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function quarterSin(r: Rotation): number {
|
|
32
|
+
switch (r) {
|
|
33
|
+
case 0: return 0;
|
|
34
|
+
case 1: return 1;
|
|
35
|
+
case 2: return 0;
|
|
36
|
+
case 3: return -1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { kindForSpiceLetter, getSpiceNodeOrder, type SpiceLetter } from '../../model/netlist';
|
|
2
|
+
import { parseQuantity } from '../../model/quantity';
|
|
3
|
+
import type {
|
|
4
|
+
CircuitDocument,
|
|
5
|
+
Component,
|
|
6
|
+
ComponentKind,
|
|
7
|
+
DocumentMetadata,
|
|
8
|
+
Point,
|
|
9
|
+
PropertyValue,
|
|
10
|
+
Terminal,
|
|
11
|
+
Warning,
|
|
12
|
+
Wire,
|
|
13
|
+
} from '../../model/types';
|
|
14
|
+
|
|
15
|
+
const GRID_SPACING = 120;
|
|
16
|
+
const GRID_COLS = 6;
|
|
17
|
+
|
|
18
|
+
type SpiceElement = Readonly<{
|
|
19
|
+
id: string;
|
|
20
|
+
letter: SpiceLetter;
|
|
21
|
+
nodes: readonly string[];
|
|
22
|
+
value: string | null;
|
|
23
|
+
model: string | null;
|
|
24
|
+
extras: readonly string[];
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
export function parseSpiceNetlist(source: string): CircuitDocument {
|
|
28
|
+
const warnings: Warning[] = [];
|
|
29
|
+
const lines = preprocessLines(source);
|
|
30
|
+
const elements: SpiceElement[] = [];
|
|
31
|
+
const directives: string[] = [];
|
|
32
|
+
let title = '';
|
|
33
|
+
|
|
34
|
+
let i = 0;
|
|
35
|
+
while (i < lines.length) {
|
|
36
|
+
const line = (lines[i] ?? '').trim();
|
|
37
|
+
if (line.length === 0) {
|
|
38
|
+
i += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (line.startsWith('.')) {
|
|
42
|
+
const head = line.split(/\s+/, 1)[0] ?? '';
|
|
43
|
+
const upper = head.toUpperCase();
|
|
44
|
+
if (upper === '.TITLE') {
|
|
45
|
+
title = line.slice(head.length).trim();
|
|
46
|
+
i += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (upper === '.END') {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (upper === '.SUBCKT') {
|
|
53
|
+
const block: string[] = [line];
|
|
54
|
+
i += 1;
|
|
55
|
+
while (i < lines.length) {
|
|
56
|
+
const next = lines[i] ?? '';
|
|
57
|
+
block.push(next);
|
|
58
|
+
if (next.trim().toUpperCase().startsWith('.ENDS')) {
|
|
59
|
+
i += 1;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
i += 1;
|
|
63
|
+
}
|
|
64
|
+
directives.push(block.join('\n'));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
directives.push(line);
|
|
68
|
+
i += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const element = parseElement(line, warnings);
|
|
73
|
+
if (element !== null) {
|
|
74
|
+
elements.push(element);
|
|
75
|
+
}
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { components, wires } = layoutElements(elements, warnings);
|
|
80
|
+
const metadata: DocumentMetadata = { name: title, description: '', partNumber: '' };
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
metadata,
|
|
84
|
+
components,
|
|
85
|
+
wires,
|
|
86
|
+
directives,
|
|
87
|
+
warnings,
|
|
88
|
+
rawAttributes: {},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function preprocessLines(source: string): readonly string[] {
|
|
93
|
+
const raw = source.replace(/\r\n/g, '\n').split('\n');
|
|
94
|
+
const out: string[] = [];
|
|
95
|
+
for (const original of raw) {
|
|
96
|
+
const trimmed = stripComment(original);
|
|
97
|
+
if (trimmed.startsWith('+') && out.length > 0) {
|
|
98
|
+
out[out.length - 1] = `${out[out.length - 1] ?? ''} ${trimmed.slice(1).trim()}`;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
out.push(trimmed);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stripComment(line: string): string {
|
|
107
|
+
const trimmed = line.replace(/\t/g, ' ').trimEnd();
|
|
108
|
+
if (trimmed.trimStart().startsWith('*')) {
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
const semiIdx = trimmed.indexOf(';');
|
|
112
|
+
return semiIdx >= 0 ? trimmed.slice(0, semiIdx).trimEnd() : trimmed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseElement(line: string, warnings: Warning[]): SpiceElement | null {
|
|
116
|
+
const tokens = line.split(/\s+/).filter((t) => t.length > 0);
|
|
117
|
+
if (tokens.length < 3) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const id = tokens[0]!;
|
|
121
|
+
const first = id.charAt(0).toUpperCase();
|
|
122
|
+
const letter = first as SpiceLetter;
|
|
123
|
+
|
|
124
|
+
switch (first) {
|
|
125
|
+
case 'R':
|
|
126
|
+
case 'C':
|
|
127
|
+
case 'L':
|
|
128
|
+
case 'V':
|
|
129
|
+
case 'I': {
|
|
130
|
+
const nodes = [tokens[1]!, tokens[2]!];
|
|
131
|
+
const value = tokens.slice(3).join(' ');
|
|
132
|
+
return { id, letter, nodes, value: value.length > 0 ? value : null, model: null, extras: [] };
|
|
133
|
+
}
|
|
134
|
+
case 'D': {
|
|
135
|
+
if (tokens.length < 4) {
|
|
136
|
+
warnings.push({ code: 'invalid-element', message: `${id}: diode requires <anode> <cathode> <model>` });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
id,
|
|
141
|
+
letter,
|
|
142
|
+
nodes: [tokens[1]!, tokens[2]!],
|
|
143
|
+
value: null,
|
|
144
|
+
model: tokens[3]!,
|
|
145
|
+
extras: tokens.slice(4),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
case 'Q': {
|
|
149
|
+
if (tokens.length < 5) {
|
|
150
|
+
warnings.push({ code: 'invalid-element', message: `${id}: BJT requires <c> <b> <e> <model>` });
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const hasSubstrate = tokens.length >= 6 && !looksLikeModelParam(tokens[5]!);
|
|
154
|
+
const nodes = tokens.slice(1, hasSubstrate ? 5 : 4);
|
|
155
|
+
const modelIndex = hasSubstrate ? 5 : 4;
|
|
156
|
+
const model = tokens[modelIndex];
|
|
157
|
+
if (model === undefined) {
|
|
158
|
+
warnings.push({ code: 'invalid-element', message: `${id}: BJT missing model` });
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return { id, letter, nodes, value: null, model, extras: tokens.slice(modelIndex + 1) };
|
|
162
|
+
}
|
|
163
|
+
case 'J': {
|
|
164
|
+
if (tokens.length < 5) {
|
|
165
|
+
warnings.push({ code: 'invalid-element', message: `${id}: JFET requires <d> <g> <s> <model>` });
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
letter,
|
|
171
|
+
nodes: [tokens[1]!, tokens[2]!, tokens[3]!],
|
|
172
|
+
value: null,
|
|
173
|
+
model: tokens[4]!,
|
|
174
|
+
extras: tokens.slice(5),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
case 'M': {
|
|
178
|
+
if (tokens.length < 6) {
|
|
179
|
+
warnings.push({ code: 'invalid-element', message: `${id}: MOSFET requires <d> <g> <s> <b> <model>` });
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
id,
|
|
184
|
+
letter,
|
|
185
|
+
nodes: [tokens[1]!, tokens[2]!, tokens[3]!, tokens[4]!],
|
|
186
|
+
value: null,
|
|
187
|
+
model: tokens[5]!,
|
|
188
|
+
extras: tokens.slice(6),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
case 'X': {
|
|
192
|
+
warnings.push({ code: 'subcircuit-instance', message: `${id}: subcircuit instances not yet supported` });
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
default:
|
|
196
|
+
warnings.push({ code: 'unknown-element', message: `${id}: unknown element type "${first}"` });
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function looksLikeModelParam(token: string): boolean {
|
|
202
|
+
return token.includes('=');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function layoutElements(
|
|
206
|
+
elements: readonly SpiceElement[],
|
|
207
|
+
warnings: Warning[],
|
|
208
|
+
): { components: Component[]; wires: Wire[] } {
|
|
209
|
+
const components: Component[] = [];
|
|
210
|
+
const nodeTerminals = new Map<string, Point[]>();
|
|
211
|
+
|
|
212
|
+
elements.forEach((element, index) => {
|
|
213
|
+
const kind = kindForSpiceLetter(element.letter);
|
|
214
|
+
const origin: Point = {
|
|
215
|
+
x: (index % GRID_COLS) * GRID_SPACING,
|
|
216
|
+
y: Math.floor(index / GRID_COLS) * GRID_SPACING,
|
|
217
|
+
};
|
|
218
|
+
const terminalNames = getSpiceNodeOrder(kind) ?? defaultTerminalNames(kind, element.nodes.length);
|
|
219
|
+
const terminals = computeTerminals(terminalNames, origin);
|
|
220
|
+
|
|
221
|
+
if (terminals.length !== element.nodes.length) {
|
|
222
|
+
warnings.push({
|
|
223
|
+
code: 'arity-mismatch',
|
|
224
|
+
message: `${element.id} (${kind}): expected ${terminals.length} nodes, got ${element.nodes.length}`,
|
|
225
|
+
componentId: element.id,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < Math.min(terminals.length, element.nodes.length); i += 1) {
|
|
230
|
+
const nodeName = element.nodes[i]!;
|
|
231
|
+
const position = terminals[i]!.position;
|
|
232
|
+
const list = nodeTerminals.get(nodeName) ?? [];
|
|
233
|
+
list.push(position);
|
|
234
|
+
nodeTerminals.set(nodeName, list);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
components.push({
|
|
238
|
+
id: element.id,
|
|
239
|
+
kind,
|
|
240
|
+
name: element.id,
|
|
241
|
+
origin,
|
|
242
|
+
rotation: 0,
|
|
243
|
+
flipped: false,
|
|
244
|
+
terminals,
|
|
245
|
+
properties: buildProperties(element),
|
|
246
|
+
sourceTypeName: null,
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const groundPosition = ensureGroundComponent(components, nodeTerminals);
|
|
251
|
+
const wires = synthesizeWires(nodeTerminals, groundPosition);
|
|
252
|
+
return { components, wires };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function ensureGroundComponent(components: Component[], nodeTerminals: Map<string, Point[]>): Point | null {
|
|
256
|
+
const groundPins = nodeTerminals.get('0');
|
|
257
|
+
if (groundPins === undefined || groundPins.length === 0) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const ground: Point = centroid(groundPins);
|
|
261
|
+
const groundY = ground.y + 80;
|
|
262
|
+
const groundOrigin: Point = { x: ground.x, y: groundY };
|
|
263
|
+
components.push({
|
|
264
|
+
id: 'GND',
|
|
265
|
+
kind: 'ground',
|
|
266
|
+
name: 'GND',
|
|
267
|
+
origin: groundOrigin,
|
|
268
|
+
rotation: 0,
|
|
269
|
+
flipped: false,
|
|
270
|
+
terminals: [{ name: 't', position: groundOrigin }],
|
|
271
|
+
properties: {},
|
|
272
|
+
sourceTypeName: null,
|
|
273
|
+
});
|
|
274
|
+
groundPins.push(groundOrigin);
|
|
275
|
+
return groundOrigin;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function synthesizeWires(nodeTerminals: ReadonlyMap<string, readonly Point[]>, groundHint: Point | null): Wire[] {
|
|
279
|
+
const wires: Wire[] = [];
|
|
280
|
+
let counter = 0;
|
|
281
|
+
for (const [nodeName, positions] of nodeTerminals) {
|
|
282
|
+
if (positions.length < 2) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const hub = nodeName === '0' && groundHint !== null ? groundHint : centroid(positions);
|
|
286
|
+
for (const position of positions) {
|
|
287
|
+
if (position.x === hub.x && position.y === hub.y) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
counter += 1;
|
|
291
|
+
wires.push({ id: `w${counter}`, endpoints: [position, hub] });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return wires;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function centroid(points: readonly Point[]): Point {
|
|
298
|
+
if (points.length === 0) {
|
|
299
|
+
return { x: 0, y: 0 };
|
|
300
|
+
}
|
|
301
|
+
let sx = 0;
|
|
302
|
+
let sy = 0;
|
|
303
|
+
for (const p of points) {
|
|
304
|
+
sx += p.x;
|
|
305
|
+
sy += p.y;
|
|
306
|
+
}
|
|
307
|
+
return { x: sx / points.length, y: sy / points.length };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function defaultTerminalNames(_kind: ComponentKind, count: number): readonly string[] {
|
|
311
|
+
return Array.from({ length: count }, (_, i) => `t${i + 1}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function computeTerminals(names: readonly string[], origin: Point): Terminal[] {
|
|
315
|
+
const half = 20;
|
|
316
|
+
if (names.length === 2) {
|
|
317
|
+
return [
|
|
318
|
+
{ name: names[0]!, position: { x: origin.x, y: origin.y - half } },
|
|
319
|
+
{ name: names[1]!, position: { x: origin.x, y: origin.y + half } },
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
if (names.length === 3) {
|
|
323
|
+
return [
|
|
324
|
+
{ name: names[0]!, position: { x: origin.x, y: origin.y - half } },
|
|
325
|
+
{ name: names[1]!, position: { x: origin.x - half, y: origin.y } },
|
|
326
|
+
{ name: names[2]!, position: { x: origin.x, y: origin.y + half } },
|
|
327
|
+
];
|
|
328
|
+
}
|
|
329
|
+
if (names.length === 4) {
|
|
330
|
+
return [
|
|
331
|
+
{ name: names[0]!, position: { x: origin.x, y: origin.y - half } },
|
|
332
|
+
{ name: names[1]!, position: { x: origin.x - half, y: origin.y } },
|
|
333
|
+
{ name: names[2]!, position: { x: origin.x, y: origin.y + half } },
|
|
334
|
+
{ name: names[3]!, position: { x: origin.x + half, y: origin.y } },
|
|
335
|
+
];
|
|
336
|
+
}
|
|
337
|
+
return names.map((name, idx) => ({
|
|
338
|
+
name,
|
|
339
|
+
position: {
|
|
340
|
+
x: origin.x + Math.cos((idx * 2 * Math.PI) / names.length) * half,
|
|
341
|
+
y: origin.y + Math.sin((idx * 2 * Math.PI) / names.length) * half,
|
|
342
|
+
},
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function buildProperties(element: SpiceElement): Readonly<Record<string, PropertyValue>> {
|
|
347
|
+
const properties: Record<string, PropertyValue> = {};
|
|
348
|
+
if (element.value !== null) {
|
|
349
|
+
const valueProp = valuePropertyName(element.letter);
|
|
350
|
+
if (valueProp !== null) {
|
|
351
|
+
const parsed = parseQuantity(element.value);
|
|
352
|
+
properties[valueProp] = parsed ?? element.value;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (element.model !== null) {
|
|
356
|
+
properties.model = element.model;
|
|
357
|
+
}
|
|
358
|
+
if (element.extras.length > 0) {
|
|
359
|
+
properties.spiceExtras = element.extras.join(' ');
|
|
360
|
+
}
|
|
361
|
+
return properties;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function valuePropertyName(letter: SpiceLetter): string | null {
|
|
365
|
+
switch (letter) {
|
|
366
|
+
case 'R': return 'R';
|
|
367
|
+
case 'C': return 'C';
|
|
368
|
+
case 'L': return 'L';
|
|
369
|
+
case 'V': return 'V';
|
|
370
|
+
case 'I': return 'I';
|
|
371
|
+
default: return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { toNetlistView, type NetlistComponent } from '../../model/netlist';
|
|
2
|
+
import type { CircuitDocument } from '../../model/types';
|
|
3
|
+
|
|
4
|
+
export function serializeSpiceNetlist(doc: CircuitDocument): string {
|
|
5
|
+
const lines: string[] = [];
|
|
6
|
+
const titleLine = doc.metadata.name.trim();
|
|
7
|
+
if (titleLine.length > 0) {
|
|
8
|
+
lines.push(`.TITLE ${titleLine}`);
|
|
9
|
+
} else {
|
|
10
|
+
lines.push('* @vessel-dsp/core — serialized netlist');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const view = toNetlistView(doc);
|
|
14
|
+
for (const entry of view.components) {
|
|
15
|
+
const formatted = formatComponent(entry);
|
|
16
|
+
if (formatted !== null) {
|
|
17
|
+
lines.push(formatted);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const directive of doc.directives) {
|
|
22
|
+
lines.push(directive);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
lines.push('.END');
|
|
26
|
+
return `${lines.join('\n')}\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatComponent(entry: NetlistComponent): string | null {
|
|
30
|
+
if (entry.spiceLetter === null) {
|
|
31
|
+
return `* ${entry.id} (${entry.kind}) skipped — needs subcircuit expansion`;
|
|
32
|
+
}
|
|
33
|
+
const id = ensurePrefix(entry.id, entry.spiceLetter);
|
|
34
|
+
const nodes = entry.nodes.join(' ');
|
|
35
|
+
const tail = entry.model ?? entry.value?.raw ?? '';
|
|
36
|
+
const extras = entry.extras.spiceExtras ?? '';
|
|
37
|
+
const parts = [id, nodes, tail, extras].filter((s) => typeof s === 'string' && s.length > 0);
|
|
38
|
+
return parts.join(' ').trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ensurePrefix(id: string, letter: string): string {
|
|
42
|
+
return id.charAt(0).toUpperCase() === letter ? id : `${letter}${id}`;
|
|
43
|
+
}
|