@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,39 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
ControlState,
|
|
3
|
+
ControlValue,
|
|
4
|
+
DeviceInterfaceProvenance,
|
|
5
|
+
ExternalControlAssignmentHint,
|
|
6
|
+
ExtractedDeviceInterface,
|
|
7
|
+
ExtractedDeviceInterfaceControl,
|
|
8
|
+
JackAudioRole,
|
|
9
|
+
JackPort,
|
|
10
|
+
JackRole,
|
|
11
|
+
Knob,
|
|
12
|
+
KnobControlMode,
|
|
13
|
+
KnobStep,
|
|
14
|
+
KnobTaper,
|
|
15
|
+
KnobValue,
|
|
16
|
+
LedIndicator,
|
|
17
|
+
LedValue,
|
|
18
|
+
Panel,
|
|
19
|
+
PanelMessage,
|
|
20
|
+
SliderControl,
|
|
21
|
+
SliderOrientation,
|
|
22
|
+
SliderRange,
|
|
23
|
+
SliderValue,
|
|
24
|
+
SwitchControl,
|
|
25
|
+
SwitchKind,
|
|
26
|
+
SwitchValue,
|
|
27
|
+
} from './types';
|
|
28
|
+
export { PANEL_PROTOCOL_VERSION } from './types';
|
|
29
|
+
export { extractDeviceInterface, extractPanel } from './extract';
|
|
30
|
+
export { isKnobPositionOnStep, knobStepSize, nearestKnobStep, snapKnobPosition } from './knobs';
|
|
31
|
+
export {
|
|
32
|
+
applyControlMessage,
|
|
33
|
+
defaultControlState,
|
|
34
|
+
isKnob,
|
|
35
|
+
isLed,
|
|
36
|
+
isSlider,
|
|
37
|
+
isSwitch,
|
|
38
|
+
validateMessage,
|
|
39
|
+
} from './protocol';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Knob, KnobStep } from './types';
|
|
2
|
+
|
|
3
|
+
const KNOB_POSITION_EPSILON = 1e-9;
|
|
4
|
+
const POSITION_ROUNDING_FACTOR = 1_000_000_000_000;
|
|
5
|
+
|
|
6
|
+
export function buildKnobSteps(count: number, labels: readonly string[] = []): readonly KnobStep[] | undefined {
|
|
7
|
+
if (!Number.isFinite(count)) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const normalizedCount = Math.trunc(count);
|
|
12
|
+
if (normalizedCount < 2) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return Array.from({ length: normalizedCount }, (_, index) => {
|
|
17
|
+
const position = roundPosition(index / (normalizedCount - 1));
|
|
18
|
+
const label = labels[index];
|
|
19
|
+
if (label !== undefined && label.length > 0) {
|
|
20
|
+
return { index, position, label };
|
|
21
|
+
}
|
|
22
|
+
return { index, position };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function nearestKnobStep(steps: readonly KnobStep[] | undefined, position: number): KnobStep | undefined {
|
|
27
|
+
if (steps === undefined || steps.length === 0 || !Number.isFinite(position)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let nearest = steps[0];
|
|
32
|
+
if (nearest === undefined) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let nearestDistance = Math.abs(position - nearest.position);
|
|
37
|
+
for (const step of steps.slice(1)) {
|
|
38
|
+
const distance = Math.abs(position - step.position);
|
|
39
|
+
if (distance < nearestDistance) {
|
|
40
|
+
nearest = step;
|
|
41
|
+
nearestDistance = distance;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return nearest;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function snapKnobPosition(knob: Pick<Knob, 'steps'>, position: number): number {
|
|
48
|
+
const nearest = nearestKnobStep(knob.steps, position);
|
|
49
|
+
return nearest?.position ?? position;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isKnobPositionOnStep(knob: Pick<Knob, 'steps'>, position: number): boolean {
|
|
53
|
+
const nearest = nearestKnobStep(knob.steps, position);
|
|
54
|
+
if (nearest === undefined) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return Math.abs(position - nearest.position) <= KNOB_POSITION_EPSILON;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function knobStepSize(knob: Pick<Knob, 'steps'>): number | undefined {
|
|
61
|
+
const count = knob.steps?.length ?? 0;
|
|
62
|
+
if (count < 2) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return roundPosition(1 / (count - 1));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function roundPosition(value: number): number {
|
|
69
|
+
return Math.round(value * POSITION_ROUNDING_FACTOR) / POSITION_ROUNDING_FACTOR;
|
|
70
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ControlState,
|
|
3
|
+
ControlValue,
|
|
4
|
+
KnobValue,
|
|
5
|
+
LedValue,
|
|
6
|
+
Panel,
|
|
7
|
+
PanelMessage,
|
|
8
|
+
SliderValue,
|
|
9
|
+
SwitchValue,
|
|
10
|
+
} from './types';
|
|
11
|
+
import { isKnobPositionOnStep, snapKnobPosition } from './knobs';
|
|
12
|
+
|
|
13
|
+
// defaultControlState builds the initial control state from a Panel descriptor.
|
|
14
|
+
// Every knob/switch lands at its declared default; every LED starts off.
|
|
15
|
+
export function defaultControlState(panel: Panel): ControlState {
|
|
16
|
+
const state: Record<string, ControlValue> = {};
|
|
17
|
+
for (const knob of panel.knobs) {
|
|
18
|
+
state[knob.id] = { kind: 'knob', position: snapKnobPosition(knob, knob.defaultPosition) };
|
|
19
|
+
}
|
|
20
|
+
for (const slider of panel.sliders ?? []) {
|
|
21
|
+
state[slider.id] = { kind: 'slider', position: slider.defaultPosition };
|
|
22
|
+
}
|
|
23
|
+
for (const sw of panel.switches) {
|
|
24
|
+
state[sw.id] = { kind: 'switch', position: sw.defaultPosition };
|
|
25
|
+
}
|
|
26
|
+
for (const led of panel.leds) {
|
|
27
|
+
state[led.id] = { kind: 'led', on: false };
|
|
28
|
+
}
|
|
29
|
+
return state;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// applyControlMessage is a pure reducer: (state, message) → state. Callers can
|
|
33
|
+
// route the same message in both directions — `control/set` from UI → DSP and
|
|
34
|
+
// `control/changed` from DSP → UI both produce the same state update.
|
|
35
|
+
export function applyControlMessage(state: ControlState, message: PanelMessage): ControlState {
|
|
36
|
+
switch (message.type) {
|
|
37
|
+
case 'panel/load':
|
|
38
|
+
return defaultControlState(message.panel);
|
|
39
|
+
case 'state/snapshot':
|
|
40
|
+
return message.state;
|
|
41
|
+
case 'state/request':
|
|
42
|
+
case 'control/error':
|
|
43
|
+
return state;
|
|
44
|
+
case 'control/set':
|
|
45
|
+
case 'control/changed':
|
|
46
|
+
return { ...state, [message.controlId]: message.value };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Type guards make it safe for consumers to interrogate ControlValues without
|
|
51
|
+
// reaching into the discriminator manually.
|
|
52
|
+
export function isKnob(value: ControlValue | undefined): value is KnobValue {
|
|
53
|
+
return value !== undefined && value.kind === 'knob';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isSlider(value: ControlValue | undefined): value is SliderValue {
|
|
57
|
+
return value !== undefined && value.kind === 'slider';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isSwitch(value: ControlValue | undefined): value is SwitchValue {
|
|
61
|
+
return value !== undefined && value.kind === 'switch';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isLed(value: ControlValue | undefined): value is LedValue {
|
|
65
|
+
return value !== undefined && value.kind === 'led';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate a message against a Panel — useful when the UI receives an event
|
|
69
|
+
// from the DSP and needs to ensure the controlId still exists. Returns null on
|
|
70
|
+
// success or a short reason string on failure.
|
|
71
|
+
export function validateMessage(panel: Panel, message: PanelMessage): string | null {
|
|
72
|
+
if (message.type === 'control/set' || message.type === 'control/changed') {
|
|
73
|
+
const knob = panel.knobs.find((k) => k.id === message.controlId);
|
|
74
|
+
if (knob !== undefined) {
|
|
75
|
+
if (message.value.kind !== 'knob') {
|
|
76
|
+
return `control "${message.controlId}" is a knob but received ${message.value.kind} value`;
|
|
77
|
+
}
|
|
78
|
+
if (!Number.isFinite(message.value.position) || message.value.position < 0 || message.value.position > 1) {
|
|
79
|
+
return `knob "${message.controlId}" position must be in [0,1]`;
|
|
80
|
+
}
|
|
81
|
+
if (!isKnobPositionOnStep(knob, message.value.position)) {
|
|
82
|
+
const stepCount = knob.steps?.length ?? 0;
|
|
83
|
+
return `knob "${message.controlId}" position must match one of ${stepCount} stepped positions`;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const slider = (panel.sliders ?? []).find((s) => s.id === message.controlId);
|
|
88
|
+
if (slider !== undefined) {
|
|
89
|
+
if (message.value.kind !== 'slider') {
|
|
90
|
+
return `control "${message.controlId}" is a slider but received ${message.value.kind} value`;
|
|
91
|
+
}
|
|
92
|
+
if (!Number.isFinite(message.value.position) || message.value.position < 0 || message.value.position > 1) {
|
|
93
|
+
return `slider "${message.controlId}" position must be in [0,1]`;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const sw = panel.switches.find((s) => s.id === message.controlId);
|
|
98
|
+
if (sw !== undefined) {
|
|
99
|
+
if (message.value.kind !== 'switch') {
|
|
100
|
+
return `control "${message.controlId}" is a switch but received ${message.value.kind} value`;
|
|
101
|
+
}
|
|
102
|
+
if (!Number.isInteger(message.value.position) || message.value.position < 0 || message.value.position >= sw.positions) {
|
|
103
|
+
return `switch "${message.controlId}" position must be in [0,${sw.positions - 1}]`;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const led = panel.leds.find((l) => l.id === message.controlId);
|
|
108
|
+
if (led !== undefined) {
|
|
109
|
+
if (message.value.kind !== 'led') {
|
|
110
|
+
return `control "${message.controlId}" is an LED but received ${message.value.kind} value`;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return `unknown control id "${message.controlId}"`;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ControlContext,
|
|
3
|
+
ControlGroup,
|
|
4
|
+
ControlInterfaceAssignmentHint,
|
|
5
|
+
ControlInterfaceBinding,
|
|
6
|
+
ControlInterfaceConnector,
|
|
7
|
+
ControlInterfacePolarity,
|
|
8
|
+
DeviceInterfaceBinding,
|
|
9
|
+
DeviceInterfaceControl,
|
|
10
|
+
PanelPlacementMetadata,
|
|
11
|
+
ParsedQuantity,
|
|
12
|
+
Warning,
|
|
13
|
+
} from '../model/types';
|
|
14
|
+
|
|
15
|
+
// ---------- Static panel descriptor (extracted from a CircuitDocument) ----------
|
|
16
|
+
|
|
17
|
+
export type KnobTaper = 'linear' | 'log' | 'reverse-log' | 'unknown';
|
|
18
|
+
export type KnobControlMode = 'continuous' | 'stepped';
|
|
19
|
+
|
|
20
|
+
export type KnobStep = Readonly<{
|
|
21
|
+
index: number;
|
|
22
|
+
position: number;
|
|
23
|
+
label?: string;
|
|
24
|
+
}>;
|
|
25
|
+
|
|
26
|
+
export type Knob = Readonly<{
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
taper: KnobTaper;
|
|
30
|
+
controlMode?: KnobControlMode;
|
|
31
|
+
defaultPosition: number;
|
|
32
|
+
steps?: readonly KnobStep[];
|
|
33
|
+
resistance?: ParsedQuantity;
|
|
34
|
+
gangGroup?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
export type SliderOrientation = 'vertical' | 'horizontal';
|
|
39
|
+
|
|
40
|
+
export type SliderRange = Readonly<{
|
|
41
|
+
min: number;
|
|
42
|
+
max: number;
|
|
43
|
+
unit?: string;
|
|
44
|
+
center?: number;
|
|
45
|
+
}>;
|
|
46
|
+
|
|
47
|
+
export type SliderControl = Readonly<{
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
defaultPosition: number;
|
|
51
|
+
orientation: SliderOrientation;
|
|
52
|
+
range?: SliderRange;
|
|
53
|
+
gangGroup?: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
export type SwitchKind = 'spst' | 'spdt' | 'sp3t' | 'sp4t' | '3pdt' | 'toggle' | 'rotary' | 'unknown';
|
|
58
|
+
|
|
59
|
+
export type SwitchControl = Readonly<{
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
switchKind: SwitchKind;
|
|
63
|
+
poles: number;
|
|
64
|
+
positions: number;
|
|
65
|
+
defaultPosition: number;
|
|
66
|
+
gangGroup?: string;
|
|
67
|
+
partNumber?: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
}>;
|
|
70
|
+
|
|
71
|
+
export type LedIndicator = Readonly<{
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
color?: string;
|
|
75
|
+
partNumber?: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
}>;
|
|
78
|
+
|
|
79
|
+
export type JackRole =
|
|
80
|
+
| 'input'
|
|
81
|
+
| 'output'
|
|
82
|
+
| 'direct-output'
|
|
83
|
+
| 'send'
|
|
84
|
+
| 'return'
|
|
85
|
+
| 'expression'
|
|
86
|
+
| 'tempo-tap'
|
|
87
|
+
| 'external-control'
|
|
88
|
+
| 'unknown';
|
|
89
|
+
|
|
90
|
+
export type JackAudioRole =
|
|
91
|
+
| 'guitar-input'
|
|
92
|
+
| 'bass-input'
|
|
93
|
+
| 'main-output'
|
|
94
|
+
| 'mono-output'
|
|
95
|
+
| 'output-a'
|
|
96
|
+
| 'output-a-mono'
|
|
97
|
+
| 'output-b'
|
|
98
|
+
| 'stereo-output-b'
|
|
99
|
+
| 'direct-output'
|
|
100
|
+
| 'dry-output'
|
|
101
|
+
| 'wet-output'
|
|
102
|
+
| (string & {});
|
|
103
|
+
|
|
104
|
+
export type ExternalControlAssignmentHint = ControlInterfaceAssignmentHint;
|
|
105
|
+
|
|
106
|
+
export type JackPort = Readonly<{
|
|
107
|
+
id: string;
|
|
108
|
+
name: string;
|
|
109
|
+
role: JackRole;
|
|
110
|
+
audioRole?: JackAudioRole;
|
|
111
|
+
impedance?: ParsedQuantity;
|
|
112
|
+
sourceTypeName?: string;
|
|
113
|
+
sourceComponentId?: string;
|
|
114
|
+
controlRole?: string;
|
|
115
|
+
interface?: string;
|
|
116
|
+
connector?: ControlInterfaceConnector;
|
|
117
|
+
assignmentHint?: ExternalControlAssignmentHint;
|
|
118
|
+
polarity?: ControlInterfacePolarity;
|
|
119
|
+
binding?: ControlInterfaceBinding;
|
|
120
|
+
description?: string;
|
|
121
|
+
}>;
|
|
122
|
+
|
|
123
|
+
export type Panel = Readonly<{
|
|
124
|
+
placement?: PanelPlacementMetadata;
|
|
125
|
+
knobs: readonly Knob[];
|
|
126
|
+
sliders?: readonly SliderControl[];
|
|
127
|
+
switches: readonly SwitchControl[];
|
|
128
|
+
leds: readonly LedIndicator[];
|
|
129
|
+
jacks: readonly JackPort[];
|
|
130
|
+
}>;
|
|
131
|
+
|
|
132
|
+
export type DeviceInterfaceProvenance =
|
|
133
|
+
| 'vdsp-declared'
|
|
134
|
+
| 'source-inferred'
|
|
135
|
+
| 'runtime-descriptor-inferred'
|
|
136
|
+
| 'control-interface-declared';
|
|
137
|
+
|
|
138
|
+
export type ExtractedDeviceInterfaceControl = DeviceInterfaceControl & Readonly<{
|
|
139
|
+
provenance: DeviceInterfaceProvenance;
|
|
140
|
+
inferredBinding?: DeviceInterfaceBinding;
|
|
141
|
+
}>;
|
|
142
|
+
|
|
143
|
+
export type ExtractedDeviceInterface = Readonly<{
|
|
144
|
+
groups: readonly ControlGroup[];
|
|
145
|
+
contexts: readonly ControlContext[];
|
|
146
|
+
controls: readonly ExtractedDeviceInterfaceControl[];
|
|
147
|
+
placement?: PanelPlacementMetadata;
|
|
148
|
+
diagnostics: readonly Warning[];
|
|
149
|
+
}>;
|
|
150
|
+
|
|
151
|
+
// ---------- Runtime control state (UI ↔ DSP wire protocol) ----------
|
|
152
|
+
|
|
153
|
+
// Every control reports its value through a tagged-union ControlValue.
|
|
154
|
+
// The same shape is used in messages going UI → DSP (`control/set`) and DSP → UI
|
|
155
|
+
// (`control/changed`, e.g. LED illumination from a level detector).
|
|
156
|
+
export type KnobValue = Readonly<{ kind: 'knob'; position: number }>;
|
|
157
|
+
export type SliderValue = Readonly<{ kind: 'slider'; position: number }>;
|
|
158
|
+
export type SwitchValue = Readonly<{ kind: 'switch'; position: number }>;
|
|
159
|
+
export type LedValue = Readonly<{ kind: 'led'; on: boolean; intensity?: number }>;
|
|
160
|
+
|
|
161
|
+
export type ControlValue = KnobValue | SliderValue | SwitchValue | LedValue;
|
|
162
|
+
|
|
163
|
+
export type ControlState = Readonly<Record<string, ControlValue>>;
|
|
164
|
+
|
|
165
|
+
// ---------- Wire protocol ----------
|
|
166
|
+
|
|
167
|
+
// Message envelope — JSON serializable. Implementations can put this on top of
|
|
168
|
+
// any transport (postMessage, WebSocket, MIDI sysex, OSC bundle, FFI marshalling).
|
|
169
|
+
// The `requestId` field is optional and lets a caller correlate request/response
|
|
170
|
+
// when the transport doesn't already preserve ordering.
|
|
171
|
+
|
|
172
|
+
export type PanelMessage =
|
|
173
|
+
| Readonly<{ type: 'panel/load'; panel: Panel; requestId?: string }>
|
|
174
|
+
| Readonly<{ type: 'state/snapshot'; state: ControlState; requestId?: string }>
|
|
175
|
+
| Readonly<{ type: 'state/request'; requestId?: string }>
|
|
176
|
+
| Readonly<{ type: 'control/set'; controlId: string; value: ControlValue; requestId?: string }>
|
|
177
|
+
| Readonly<{ type: 'control/changed'; controlId: string; value: ControlValue; requestId?: string }>
|
|
178
|
+
| Readonly<{ type: 'control/error'; controlId: string; reason: string; requestId?: string }>;
|
|
179
|
+
|
|
180
|
+
export const PANEL_PROTOCOL_VERSION = 1 as const;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { propertyStringValue } from '../model/properties';
|
|
2
|
+
import type { CircuitDocument, Point, PropertyValue } from '../model/types';
|
|
3
|
+
import { computeLabelTextBoxLayout, shouldRenderLabelTextBox } from './label-layout';
|
|
4
|
+
|
|
5
|
+
export type Bounds = Readonly<{
|
|
6
|
+
minX: number;
|
|
7
|
+
minY: number;
|
|
8
|
+
maxX: number;
|
|
9
|
+
maxY: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PADDING = 60;
|
|
15
|
+
const FALLBACK_HALF = 160;
|
|
16
|
+
|
|
17
|
+
export function computeDocumentBounds(doc: CircuitDocument, padding: number = DEFAULT_PADDING): Bounds {
|
|
18
|
+
const points: Point[] = [];
|
|
19
|
+
|
|
20
|
+
for (const component of doc.components) {
|
|
21
|
+
points.push(component.origin);
|
|
22
|
+
if (component.kind === 'label') {
|
|
23
|
+
const text = stringValue(component.properties.Text) ?? component.name;
|
|
24
|
+
const subtext = stringValue(component.properties.Subtext);
|
|
25
|
+
if (shouldRenderLabelTextBox(text, subtext)) {
|
|
26
|
+
const box = computeLabelTextBoxLayout(text, subtext);
|
|
27
|
+
points.push({
|
|
28
|
+
x: component.origin.x + box.width,
|
|
29
|
+
y: component.origin.y + box.height,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const terminal of component.terminals) {
|
|
34
|
+
points.push(terminal.position);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const wire of doc.wires) {
|
|
38
|
+
points.push(wire.endpoints[0], wire.endpoints[1]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (points.length === 0) {
|
|
42
|
+
const width = FALLBACK_HALF * 2;
|
|
43
|
+
return {
|
|
44
|
+
minX: -FALLBACK_HALF,
|
|
45
|
+
minY: -FALLBACK_HALF,
|
|
46
|
+
maxX: FALLBACK_HALF,
|
|
47
|
+
maxY: FALLBACK_HALF,
|
|
48
|
+
width,
|
|
49
|
+
height: width,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
54
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
55
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
56
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
57
|
+
for (const p of points) {
|
|
58
|
+
if (p.x < minX) minX = p.x;
|
|
59
|
+
if (p.y < minY) minY = p.y;
|
|
60
|
+
if (p.x > maxX) maxX = p.x;
|
|
61
|
+
if (p.y > maxY) maxY = p.y;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const paddedMinX = minX - padding;
|
|
65
|
+
const paddedMinY = minY - padding;
|
|
66
|
+
const paddedMaxX = maxX + padding;
|
|
67
|
+
const paddedMaxY = maxY + padding;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
minX: paddedMinX,
|
|
71
|
+
minY: paddedMinY,
|
|
72
|
+
maxX: paddedMaxX,
|
|
73
|
+
maxY: paddedMaxY,
|
|
74
|
+
width: paddedMaxX - paddedMinX,
|
|
75
|
+
height: paddedMaxY - paddedMinY,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function viewBoxString(bounds: Bounds): string {
|
|
80
|
+
return `${bounds.minX} ${bounds.minY} ${bounds.width} ${bounds.height}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stringValue(value: PropertyValue | undefined): string | null {
|
|
84
|
+
return propertyStringValue(value);
|
|
85
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Bounds } from './bounds';
|
|
2
|
+
import type { Component } from '../model/types';
|
|
3
|
+
|
|
4
|
+
// Every component renders in a uniform square container centered on its origin.
|
|
5
|
+
// HALF_SIZE=20 matches the symbol viewBox (−25..25 with body content within ±20).
|
|
6
|
+
// Lead axes that extend beyond ±20 (potentiometer ±40, op-amp ±30) intentionally
|
|
7
|
+
// reach OUT of the container — they're connection lines to wires, not symbol body.
|
|
8
|
+
const HALF_SIZE = 20;
|
|
9
|
+
|
|
10
|
+
export function computeComponentBox(component: Component): Bounds {
|
|
11
|
+
const minX = component.origin.x - HALF_SIZE;
|
|
12
|
+
const minY = component.origin.y - HALF_SIZE;
|
|
13
|
+
const maxX = component.origin.x + HALF_SIZE;
|
|
14
|
+
const maxY = component.origin.y + HALF_SIZE;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
minX,
|
|
18
|
+
minY,
|
|
19
|
+
maxX,
|
|
20
|
+
maxY,
|
|
21
|
+
width: maxX - minX,
|
|
22
|
+
height: maxY - minY,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ComponentKind } from '../model/types';
|
|
2
|
+
|
|
3
|
+
const KIND_COLOR: Readonly<Record<ComponentKind, string>> = {
|
|
4
|
+
resistor: '#3b82f6',
|
|
5
|
+
capacitor: '#3b82f6',
|
|
6
|
+
inductor: '#3b82f6',
|
|
7
|
+
potentiometer: '#3b82f6',
|
|
8
|
+
'variable-resistor': '#3b82f6',
|
|
9
|
+
transformer: '#14b8a6',
|
|
10
|
+
diode: '#ef4444',
|
|
11
|
+
led: '#ef4444',
|
|
12
|
+
bjt: '#ef4444',
|
|
13
|
+
jfet: '#ef4444',
|
|
14
|
+
mosfet: '#ef4444',
|
|
15
|
+
opamp: '#14b8a6',
|
|
16
|
+
ota: '#14b8a6',
|
|
17
|
+
optocoupler: '#14b8a6',
|
|
18
|
+
bbd: '#14b8a6',
|
|
19
|
+
'delay-ic': '#14b8a6',
|
|
20
|
+
'power-amp': '#14b8a6',
|
|
21
|
+
regulator: '#f97316',
|
|
22
|
+
'analog-switch': '#eab308',
|
|
23
|
+
flipflop: '#a855f7',
|
|
24
|
+
ic: '#14b8a6',
|
|
25
|
+
triode: '#a855f7',
|
|
26
|
+
pentode: '#a855f7',
|
|
27
|
+
'tube-diode': '#a855f7',
|
|
28
|
+
'voltage-source': '#f97316',
|
|
29
|
+
'current-source': '#f97316',
|
|
30
|
+
battery: '#f97316',
|
|
31
|
+
rail: '#f97316',
|
|
32
|
+
ground: '#64748b',
|
|
33
|
+
switch: '#eab308',
|
|
34
|
+
jack: '#ec4899',
|
|
35
|
+
label: '#64748b',
|
|
36
|
+
'named-wire': '#64748b',
|
|
37
|
+
port: '#64748b',
|
|
38
|
+
unsupported: '#94a3b8',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function colorForKind(kind: ComponentKind): string {
|
|
42
|
+
return KIND_COLOR[kind];
|
|
43
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { CircuitDocument, Point, Wire } from '../model/types';
|
|
2
|
+
|
|
3
|
+
export type HangingEndpoint = Readonly<{
|
|
4
|
+
wireId: string;
|
|
5
|
+
point: Point;
|
|
6
|
+
// 0 = first endpoint (A), 1 = second endpoint (B)
|
|
7
|
+
endpointIndex: 0 | 1;
|
|
8
|
+
}>;
|
|
9
|
+
|
|
10
|
+
// A wire endpoint is "hanging" when it doesn't actually connect to anything:
|
|
11
|
+
// - It doesn't coincide with any component terminal position
|
|
12
|
+
// - It doesn't coincide with another wire's endpoint
|
|
13
|
+
// - It doesn't lie on another wire's body (T-junction)
|
|
14
|
+
// These show up as floating dots in the schematic view and almost always
|
|
15
|
+
// indicate a fixture data bug.
|
|
16
|
+
export function findHangingEndpoints(doc: CircuitDocument): readonly HangingEndpoint[] {
|
|
17
|
+
const terminalSet = new Set<string>();
|
|
18
|
+
for (const component of doc.components) {
|
|
19
|
+
for (const terminal of component.terminals) {
|
|
20
|
+
terminalSet.add(pointKey(terminal.position));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const endpointCounts = new Map<string, number>();
|
|
25
|
+
for (const wire of doc.wires) {
|
|
26
|
+
endpointCounts.set(pointKey(wire.endpoints[0]), (endpointCounts.get(pointKey(wire.endpoints[0])) ?? 0) + 1);
|
|
27
|
+
endpointCounts.set(pointKey(wire.endpoints[1]), (endpointCounts.get(pointKey(wire.endpoints[1])) ?? 0) + 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hanging: HangingEndpoint[] = [];
|
|
31
|
+
for (const wire of doc.wires) {
|
|
32
|
+
for (const [endpointIndex, point] of [[0, wire.endpoints[0]] as const, [1, wire.endpoints[1]] as const]) {
|
|
33
|
+
if (isConnected(point, wire, doc.wires, terminalSet, endpointCounts)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
hanging.push({ wireId: wire.id, point, endpointIndex });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return hanging;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isConnected(
|
|
43
|
+
point: Point,
|
|
44
|
+
owningWire: Wire,
|
|
45
|
+
allWires: readonly Wire[],
|
|
46
|
+
terminalSet: ReadonlySet<string>,
|
|
47
|
+
endpointCounts: ReadonlyMap<string, number>,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (terminalSet.has(pointKey(point))) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
// Shared with another wire's endpoint?
|
|
53
|
+
const count = endpointCounts.get(pointKey(point)) ?? 0;
|
|
54
|
+
if (count > 1) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
// Lies on another wire's body (T-junction)?
|
|
58
|
+
for (const other of allWires) {
|
|
59
|
+
if (other.id === owningWire.id) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (pointOnSegmentInterior(point, other.endpoints[0], other.endpoints[1])) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pointOnSegmentInterior(p: Point, a: Point, b: Point): boolean {
|
|
70
|
+
const cross = (p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x);
|
|
71
|
+
if (cross !== 0) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const minX = Math.min(a.x, b.x);
|
|
75
|
+
const maxX = Math.max(a.x, b.x);
|
|
76
|
+
const minY = Math.min(a.y, b.y);
|
|
77
|
+
const maxY = Math.max(a.y, b.y);
|
|
78
|
+
if (minX === maxX && minY === maxY) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const inXRange = p.x > minX && p.x < maxX;
|
|
82
|
+
const inYRange = p.y > minY && p.y < maxY;
|
|
83
|
+
if (minX === maxX) {
|
|
84
|
+
return inYRange;
|
|
85
|
+
}
|
|
86
|
+
if (minY === maxY) {
|
|
87
|
+
return inXRange;
|
|
88
|
+
}
|
|
89
|
+
return inXRange && inYRange;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pointKey(p: Point): string {
|
|
93
|
+
return `${p.x},${p.y}`;
|
|
94
|
+
}
|