@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,142 @@
1
+ import type { CircuitDocument } from '../model/types';
2
+ import { applyDocumentCommand, type DocumentCommand } from './commands';
3
+
4
+ export type EditorState = Readonly<{
5
+ document: CircuitDocument;
6
+ selectedId: string | null;
7
+ selectedWireId: string | null;
8
+ past: readonly CircuitDocument[];
9
+ future: readonly CircuitDocument[];
10
+ }>;
11
+
12
+ export type EditorCommand =
13
+ | DocumentCommand
14
+ | Readonly<{ type: 'replace-document'; document: CircuitDocument }>
15
+ | Readonly<{ type: 'select'; componentId: string | null }>
16
+ | Readonly<{ type: 'select-wire'; wireId: string | null }>
17
+ | Readonly<{ type: 'undo' }>
18
+ | Readonly<{ type: 'redo' }>;
19
+
20
+ const HISTORY_LIMIT = 200;
21
+
22
+ export function createEditorState(document: CircuitDocument): EditorState {
23
+ return {
24
+ document,
25
+ selectedId: null,
26
+ selectedWireId: null,
27
+ past: [],
28
+ future: [],
29
+ };
30
+ }
31
+
32
+ export function applyEditorCommand(state: EditorState, command: EditorCommand): EditorState {
33
+ switch (command.type) {
34
+ case 'select':
35
+ if (state.selectedId === command.componentId && state.selectedWireId === null) {
36
+ return state;
37
+ }
38
+ return { ...state, selectedId: command.componentId, selectedWireId: null };
39
+ case 'select-wire':
40
+ if (state.selectedWireId === command.wireId && state.selectedId === null) {
41
+ return state;
42
+ }
43
+ return { ...state, selectedWireId: command.wireId, selectedId: null };
44
+ case 'replace-document':
45
+ return replaceDocument(state, command.document);
46
+ case 'undo':
47
+ return undo(state);
48
+ case 'redo':
49
+ return redo(state);
50
+ default:
51
+ return applyAndPush(state, command);
52
+ }
53
+ }
54
+
55
+ export function canUndo(state: EditorState): boolean {
56
+ return state.past.length > 0;
57
+ }
58
+
59
+ export function canRedo(state: EditorState): boolean {
60
+ return state.future.length > 0;
61
+ }
62
+
63
+ export function resetEditorState(document: CircuitDocument): EditorState {
64
+ return createEditorState(document);
65
+ }
66
+
67
+ function applyAndPush(state: EditorState, command: DocumentCommand): EditorState {
68
+ const nextDocument = applyDocumentCommand(state.document, command);
69
+ if (nextDocument === state.document) {
70
+ return state;
71
+ }
72
+ const past = [...state.past, state.document].slice(-HISTORY_LIMIT);
73
+ const selectedId = command.type === 'delete-component' && state.selectedId === command.componentId
74
+ ? null
75
+ : state.selectedId;
76
+ const selectedWireId = wireSelectionAfterCommand(state.selectedWireId, command);
77
+ return {
78
+ document: nextDocument,
79
+ selectedId,
80
+ selectedWireId,
81
+ past,
82
+ future: [],
83
+ };
84
+ }
85
+
86
+ function replaceDocument(state: EditorState, document: CircuitDocument): EditorState {
87
+ if (document === state.document) {
88
+ return state;
89
+ }
90
+ const past = [...state.past, state.document].slice(-HISTORY_LIMIT);
91
+ return {
92
+ document,
93
+ selectedId: null,
94
+ selectedWireId: null,
95
+ past,
96
+ future: [],
97
+ };
98
+ }
99
+
100
+ function wireSelectionAfterCommand(
101
+ selectedWireId: string | null,
102
+ command: DocumentCommand,
103
+ ): string | null {
104
+ if (selectedWireId === null) {
105
+ return null;
106
+ }
107
+ if (command.type === 'delete-wire' && command.wireId === selectedWireId) {
108
+ return null;
109
+ }
110
+ if (command.type === 'delete-wires' && command.wireIds.includes(selectedWireId)) {
111
+ return null;
112
+ }
113
+ return selectedWireId;
114
+ }
115
+
116
+ function undo(state: EditorState): EditorState {
117
+ const previous = state.past[state.past.length - 1];
118
+ if (previous === undefined) {
119
+ return state;
120
+ }
121
+ return {
122
+ document: previous,
123
+ selectedId: state.selectedId,
124
+ selectedWireId: state.selectedWireId,
125
+ past: state.past.slice(0, -1),
126
+ future: [state.document, ...state.future],
127
+ };
128
+ }
129
+
130
+ function redo(state: EditorState): EditorState {
131
+ const next = state.future[0];
132
+ if (next === undefined) {
133
+ return state;
134
+ }
135
+ return {
136
+ document: next,
137
+ selectedId: state.selectedId,
138
+ selectedWireId: state.selectedWireId,
139
+ past: [...state.past, state.document],
140
+ future: state.future.slice(1),
141
+ };
142
+ }
@@ -0,0 +1,11 @@
1
+ export type { DocumentCommand } from './commands';
2
+ export { applyDocumentCommand } from './commands';
3
+
4
+ export type { CreateComponentArgs } from './factory';
5
+ export { buildComponent } from './factory';
6
+
7
+ export type { TidyLayoutOptions } from './layout';
8
+ export { tidyDocumentLayout } from './layout';
9
+
10
+ export type { EditorCommand, EditorState } from './history';
11
+ export { applyEditorCommand, canRedo, canUndo, createEditorState, resetEditorState } from './history';
@@ -0,0 +1,207 @@
1
+ import type { CircuitDocument, Component, Point, Wire } from '../model/types';
2
+
3
+ export type TidyLayoutOptions = Readonly<{
4
+ spacing?: number;
5
+ margin?: number;
6
+ maxSearchRadius?: number;
7
+ }>;
8
+
9
+ type LayoutBox = Readonly<{
10
+ minX: number;
11
+ minY: number;
12
+ maxX: number;
13
+ maxY: number;
14
+ }>;
15
+
16
+ const DISPLAY_HALF = 20;
17
+ const LABEL_BASELINE_OFFSET = 32;
18
+ const LABEL_DESCENDER = 6;
19
+ const LABEL_CHAR_WIDTH = 6;
20
+ const DEFAULT_SPACING = 64;
21
+ const DEFAULT_MARGIN = 4;
22
+
23
+ export function tidyDocumentLayout(
24
+ doc: CircuitDocument,
25
+ options: TidyLayoutOptions = {},
26
+ ): CircuitDocument {
27
+ if (doc.components.length < 2) {
28
+ return doc;
29
+ }
30
+
31
+ const spacing = positiveOrDefault(options.spacing, DEFAULT_SPACING);
32
+ const margin = nonNegativeOrDefault(options.margin, DEFAULT_MARGIN);
33
+ const maxSearchRadius = Math.max(
34
+ 1,
35
+ Math.ceil(options.maxSearchRadius ?? doc.components.length + 4),
36
+ );
37
+
38
+ const placed: LayoutBox[] = [];
39
+ const originsById = new Map<string, Point>();
40
+
41
+ for (let index = 0; index < doc.components.length; index += 1) {
42
+ const component = doc.components[index]!;
43
+ const origin = overlapsAny(boxForComponent(component, component.origin, margin), placed)
44
+ ? freeOriginNear(
45
+ component,
46
+ [
47
+ ...placed,
48
+ ...doc.components.slice(index + 1).map((future) => boxForComponent(future, future.origin, margin)),
49
+ ],
50
+ spacing,
51
+ margin,
52
+ maxSearchRadius,
53
+ )
54
+ : component.origin;
55
+
56
+ placed.push(boxForComponent(component, origin, margin));
57
+ if (!pointEquals(origin, component.origin)) {
58
+ originsById.set(component.id, origin);
59
+ }
60
+ }
61
+
62
+ if (originsById.size === 0) {
63
+ return doc;
64
+ }
65
+
66
+ return moveComponents(doc, originsById);
67
+ }
68
+
69
+ function freeOriginNear(
70
+ component: Component,
71
+ occupied: readonly LayoutBox[],
72
+ spacing: number,
73
+ margin: number,
74
+ maxSearchRadius: number,
75
+ ): Point {
76
+ for (let radius = 1; radius <= maxSearchRadius; radius += 1) {
77
+ for (const offset of candidateOffsets(radius)) {
78
+ const candidate = {
79
+ x: component.origin.x + offset.x * spacing,
80
+ y: component.origin.y + offset.y * spacing,
81
+ };
82
+ if (!overlapsAny(boxForComponent(component, candidate, margin), occupied)) {
83
+ return candidate;
84
+ }
85
+ }
86
+ }
87
+
88
+ return { x: component.origin.x + (maxSearchRadius + 1) * spacing, y: component.origin.y };
89
+ }
90
+
91
+ function candidateOffsets(radius: number): readonly Point[] {
92
+ const offsets: Point[] = [];
93
+ for (let y = -radius; y <= radius; y += 1) {
94
+ for (let x = -radius; x <= radius; x += 1) {
95
+ if (Math.max(Math.abs(x), Math.abs(y)) === radius) {
96
+ offsets.push({ x, y });
97
+ }
98
+ }
99
+ }
100
+ return offsets.sort(compareOffsets);
101
+ }
102
+
103
+ function compareOffsets(a: Point, b: Point): number {
104
+ return distanceSq(a) - distanceSq(b) ||
105
+ Math.abs(a.y) - Math.abs(b.y) ||
106
+ directionRank(a) - directionRank(b) ||
107
+ a.y - b.y ||
108
+ a.x - b.x;
109
+ }
110
+
111
+ function directionRank(point: Point): number {
112
+ if (point.y === 0 && point.x > 0) return 0;
113
+ if (point.x === 0 && point.y > 0) return 1;
114
+ if (point.y === 0 && point.x < 0) return 2;
115
+ if (point.x === 0 && point.y < 0) return 3;
116
+ return 4;
117
+ }
118
+
119
+ function distanceSq(point: Point): number {
120
+ return point.x * point.x + point.y * point.y;
121
+ }
122
+
123
+ function moveComponents(doc: CircuitDocument, originsById: ReadonlyMap<string, Point>): CircuitDocument {
124
+ const terminalMoves = new Map<string, Point>();
125
+ const components = doc.components.map((component) => {
126
+ const origin = originsById.get(component.id);
127
+ if (origin === undefined) {
128
+ return component;
129
+ }
130
+ const dx = origin.x - component.origin.x;
131
+ const dy = origin.y - component.origin.y;
132
+ const terminals = component.terminals.map((terminal) => {
133
+ const moved = shiftPoint(terminal.position, dx, dy);
134
+ terminalMoves.set(pointKey(terminal.position), moved);
135
+ return { name: terminal.name, position: moved };
136
+ });
137
+ return { ...component, origin, terminals };
138
+ });
139
+
140
+ const wires = doc.wires.map((wire) => remapWire(wire, terminalMoves));
141
+ return { ...doc, components, wires };
142
+ }
143
+
144
+ function remapWire(wire: Wire, terminalMoves: ReadonlyMap<string, Point>): Wire {
145
+ const a = terminalMoves.get(pointKey(wire.endpoints[0]));
146
+ const b = terminalMoves.get(pointKey(wire.endpoints[1]));
147
+ if (a === undefined && b === undefined) {
148
+ return wire;
149
+ }
150
+ const endpoints: readonly [Point, Point] = [
151
+ a ?? wire.endpoints[0],
152
+ b ?? wire.endpoints[1],
153
+ ];
154
+ return { ...wire, endpoints };
155
+ }
156
+
157
+ function shiftPoint(point: Point, dx: number, dy: number): Point {
158
+ return { x: point.x + dx, y: point.y + dy };
159
+ }
160
+
161
+ function boxForComponent(component: Component, origin: Point, margin: number): LayoutBox {
162
+ const labelHalfWidth = Math.max(DISPLAY_HALF, component.name.length * LABEL_CHAR_WIDTH / 2);
163
+ let minX = Math.min(origin.x - DISPLAY_HALF, origin.x - labelHalfWidth);
164
+ let minY = origin.y - DISPLAY_HALF;
165
+ let maxX = Math.max(origin.x + DISPLAY_HALF, origin.x + labelHalfWidth);
166
+ let maxY = Math.max(origin.y + DISPLAY_HALF, origin.y + LABEL_BASELINE_OFFSET + LABEL_DESCENDER);
167
+
168
+ for (const terminal of component.terminals) {
169
+ const x = origin.x + terminal.position.x - component.origin.x;
170
+ const y = origin.y + terminal.position.y - component.origin.y;
171
+ minX = Math.min(minX, x);
172
+ minY = Math.min(minY, y);
173
+ maxX = Math.max(maxX, x);
174
+ maxY = Math.max(maxY, y);
175
+ }
176
+
177
+ return {
178
+ minX: minX - margin,
179
+ minY: minY - margin,
180
+ maxX: maxX + margin,
181
+ maxY: maxY + margin,
182
+ };
183
+ }
184
+
185
+ function overlapsAny(box: LayoutBox, placed: readonly LayoutBox[]): boolean {
186
+ return placed.some((other) => boxesOverlap(box, other));
187
+ }
188
+
189
+ function boxesOverlap(a: LayoutBox, b: LayoutBox): boolean {
190
+ return a.minX < b.maxX && a.maxX > b.minX && a.minY < b.maxY && a.maxY > b.minY;
191
+ }
192
+
193
+ function positiveOrDefault(value: number | undefined, fallback: number): number {
194
+ return value !== undefined && value > 0 ? value : fallback;
195
+ }
196
+
197
+ function nonNegativeOrDefault(value: number | undefined, fallback: number): number {
198
+ return value !== undefined && value >= 0 ? value : fallback;
199
+ }
200
+
201
+ function pointEquals(a: Point, b: Point): boolean {
202
+ return a.x === b.x && a.y === b.y;
203
+ }
204
+
205
+ function pointKey(point: Point): string {
206
+ return `${point.x},${point.y}`;
207
+ }