@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,985 @@
1
+ import { propertyQuantityValue, propertyStringValue } from './properties';
2
+ import { extractPanel } from '../panel/extract';
3
+ import type {
4
+ CircuitDocument,
5
+ Component,
6
+ ComponentKind,
7
+ DeviceInterfaceBinding,
8
+ DeviceInterfaceControl,
9
+ PanelControlKind,
10
+ PanelElementPlacement,
11
+ PanelFace,
12
+ ParsedQuantity,
13
+ PropertyValue,
14
+ } from './types';
15
+
16
+ export type ValidationSeverity = 'error' | 'warning';
17
+
18
+ export type ValidationCode =
19
+ | 'value-required'
20
+ | 'model-required'
21
+ | 'value-unparseable'
22
+ | 'value-out-of-range'
23
+ | 'unit-mismatch'
24
+ | 'unsupported-component'
25
+ | 'invalid-jack-role'
26
+ | 'invalid-jack-interface'
27
+ | 'invalid-jack-audio-role'
28
+ | 'descriptor-control-empty'
29
+ | 'descriptor-mode-label-mismatch'
30
+ | 'duplicate-device-interface-control-id'
31
+ | 'invalid-device-interface-token'
32
+ | 'control-group-context-unresolved'
33
+ | 'device-interface-group-unresolved'
34
+ | 'device-interface-context-unresolved'
35
+ | 'device-interface-binding-unresolved'
36
+ | 'device-interface-duplicate-role'
37
+ | 'panel-interface-control-unresolved'
38
+ | 'panel-binding-unresolved'
39
+ | 'panel-control-unresolved'
40
+ | 'panel-kind-mismatch'
41
+ | 'panel-cell-collision'
42
+ | 'duplicate-id'
43
+ | 'degenerate-wire';
44
+
45
+ export type ValidationIssue = Readonly<{
46
+ code: ValidationCode;
47
+ severity: ValidationSeverity;
48
+ message: string;
49
+ componentId?: string;
50
+ property?: string;
51
+ wireId?: string;
52
+ }>;
53
+
54
+ export type QuantityRule = Readonly<{
55
+ kind: 'quantity';
56
+ name: string;
57
+ required: boolean;
58
+ aliases?: readonly string[];
59
+ unit?: string;
60
+ min?: number;
61
+ max?: number;
62
+ }>;
63
+
64
+ export type StringRule = Readonly<{
65
+ kind: 'string';
66
+ name: string;
67
+ required: boolean;
68
+ aliases?: readonly string[];
69
+ }>;
70
+
71
+ export type PropertyRule = QuantityRule | StringRule;
72
+
73
+ const MODEL_ALIASES = ['Model', 'Type', 'partNumber', 'PartNumber'] as const;
74
+
75
+ // Short source-type names (last dotted segment) that represent an "ideal" component variant —
76
+ // no model name is required because the component is a mathematical abstraction.
77
+ const IDEAL_SOURCE_TYPES: ReadonlySet<string> = new Set(['IdealOpAmp']);
78
+
79
+ // Per-kind property names that, if present, satisfy the "needs a model" requirement.
80
+ // LiveSPICE stores tube Koren parameters and opamp small-signal parameters inline; when those
81
+ // are present, the parameters ARE the model definition and no separate model name is needed.
82
+ const INLINE_MODEL_PARAMETERS: Partial<Record<ComponentKind, readonly string[]>> = {
83
+ opamp: ['Rin', 'Rout', 'Aol', 'GBP', 'SupplyVoltage'],
84
+ triode: ['Mu', 'K', 'Kp', 'Kvb', 'Ex', 'Kg'],
85
+ pentode: ['Mu', 'K', 'Kp', 'Kvb', 'Ex', 'Kg', 'Kg1', 'Kg2'],
86
+ };
87
+
88
+ const RUNTIME_DESCRIPTOR_CONTROL_PROPERTIES = [
89
+ 'TimeControl',
90
+ 'FeedbackControl',
91
+ 'MixControl',
92
+ 'LevelControl',
93
+ 'ToneControl',
94
+ 'ModRateControl',
95
+ 'ModDepthControl',
96
+ 'ModeControl',
97
+ 'TempoTapControl',
98
+ 'TapTempoControl',
99
+ 'TempoControl',
100
+ 'DirectOutputJack',
101
+ 'DirectOutJack',
102
+ 'DirectOutputControl',
103
+ 'DirectOutControl',
104
+ ] as const;
105
+
106
+ type ResolvedPanelElement = Readonly<{
107
+ id: string;
108
+ componentId: string;
109
+ kind: PanelControlKind;
110
+ }>;
111
+
112
+ const KIND_RULES: Partial<Record<ComponentKind, readonly PropertyRule[]>> = {
113
+ resistor: [{
114
+ kind: 'quantity', name: 'R', required: true, unit: 'Ω',
115
+ min: 1e-9, max: 1e9, aliases: ['Resistance', 'resistance', 'r'],
116
+ }],
117
+ 'variable-resistor': [{
118
+ kind: 'quantity', name: 'R', required: true, unit: 'Ω',
119
+ min: 1e-9, max: 1e9, aliases: ['Resistance', 'resistance', 'r'],
120
+ }],
121
+ potentiometer: [
122
+ {
123
+ kind: 'quantity', name: 'R', required: true, unit: 'Ω',
124
+ min: 1e-9, max: 1e9, aliases: ['Resistance', 'totalResistance'],
125
+ },
126
+ { kind: 'string', name: 'taper', required: false, aliases: ['Taper'] },
127
+ ],
128
+ capacitor: [{
129
+ kind: 'quantity', name: 'C', required: true, unit: 'F',
130
+ min: 1e-15, max: 1, aliases: ['Capacitance', 'capacitance', 'c'],
131
+ }],
132
+ inductor: [{
133
+ kind: 'quantity', name: 'L', required: true, unit: 'H',
134
+ min: 1e-12, max: 100, aliases: ['Inductance', 'inductance', 'l'],
135
+ }],
136
+ 'voltage-source': [{
137
+ kind: 'quantity', name: 'V', required: true, unit: 'V',
138
+ aliases: ['Voltage', 'voltage', 'v'],
139
+ }],
140
+ 'current-source': [{
141
+ kind: 'quantity', name: 'I', required: true, unit: 'A',
142
+ aliases: ['Current', 'current', 'i'],
143
+ }],
144
+ battery: [{
145
+ kind: 'quantity', name: 'V', required: true, unit: 'V',
146
+ aliases: ['Voltage', 'voltage', 'v'],
147
+ }],
148
+ rail: [{
149
+ kind: 'quantity', name: 'V', required: true, unit: 'V',
150
+ aliases: ['Voltage', 'voltage', 'v'],
151
+ }],
152
+ diode: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
153
+ led: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
154
+ bjt: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
155
+ jfet: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
156
+ mosfet: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
157
+ opamp: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
158
+ triode: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
159
+ pentode: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
160
+ 'tube-diode': [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
161
+ optocoupler: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
162
+ transformer: [{ kind: 'string', name: 'model', required: false, aliases: [...MODEL_ALIASES] }],
163
+ ota: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
164
+ bbd: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
165
+ 'delay-ic': [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
166
+ 'power-amp': [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
167
+ regulator: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
168
+ 'analog-switch': [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
169
+ flipflop: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
170
+ ic: [{ kind: 'string', name: 'model', required: true, aliases: [...MODEL_ALIASES] }],
171
+ };
172
+
173
+ export function getRulesForKind(kind: ComponentKind): readonly PropertyRule[] {
174
+ return KIND_RULES[kind] ?? [];
175
+ }
176
+
177
+ export function validateComponent(
178
+ component: Component,
179
+ rules: readonly PropertyRule[] = getRulesForKind(component.kind),
180
+ ): readonly ValidationIssue[] {
181
+ const issues: ValidationIssue[] = [];
182
+
183
+ for (const rule of rules) {
184
+ const value = findProperty(component, rule);
185
+
186
+ if (value === undefined) {
187
+ if (rule.required && !isRequirementWaived(component, rule)) {
188
+ issues.push(missingPropertyIssue(component, rule));
189
+ }
190
+ continue;
191
+ }
192
+
193
+ if (rule.kind === 'string') {
194
+ if (typeof value !== 'string' || value.trim().length === 0) {
195
+ if (rule.required && !isRequirementWaived(component, rule)) {
196
+ issues.push(missingPropertyIssue(component, rule));
197
+ }
198
+ }
199
+ continue;
200
+ }
201
+
202
+ const quantity = coerceQuantity(value);
203
+ if (quantity === null) {
204
+ if (typeof value === 'string' && isRawQuantityExpression(value)) {
205
+ continue;
206
+ }
207
+ issues.push({
208
+ code: 'value-unparseable',
209
+ severity: 'error',
210
+ message: `${component.id}: property "${rule.name}" could not be parsed as a quantity`,
211
+ componentId: component.id,
212
+ property: rule.name,
213
+ });
214
+ continue;
215
+ }
216
+
217
+ if (rule.unit !== undefined && rule.unit.length > 0 && quantity.unit.length > 0 && quantity.unit !== rule.unit) {
218
+ issues.push({
219
+ code: 'unit-mismatch',
220
+ severity: 'warning',
221
+ message: `${component.id}: property "${rule.name}" has unit "${quantity.unit}" but expected "${rule.unit}"`,
222
+ componentId: component.id,
223
+ property: rule.name,
224
+ });
225
+ }
226
+
227
+ if (rule.min !== undefined && quantity.value < rule.min) {
228
+ issues.push({
229
+ code: 'value-out-of-range',
230
+ severity: 'error',
231
+ message: `${component.id}: property "${rule.name}" value ${quantity.value} is below minimum ${rule.min}`,
232
+ componentId: component.id,
233
+ property: rule.name,
234
+ });
235
+ }
236
+ if (rule.max !== undefined && quantity.value > rule.max) {
237
+ issues.push({
238
+ code: 'value-out-of-range',
239
+ severity: 'error',
240
+ message: `${component.id}: property "${rule.name}" value ${quantity.value} is above maximum ${rule.max}`,
241
+ componentId: component.id,
242
+ property: rule.name,
243
+ });
244
+ }
245
+ }
246
+
247
+ return issues;
248
+ }
249
+
250
+ export function validateDocument(doc: CircuitDocument): readonly ValidationIssue[] {
251
+ const issues: ValidationIssue[] = [];
252
+ const seen = new Set<string>();
253
+
254
+ for (const component of doc.components) {
255
+ if (seen.has(component.id)) {
256
+ issues.push({
257
+ code: 'duplicate-id',
258
+ severity: 'error',
259
+ message: `Duplicate component id "${component.id}"`,
260
+ componentId: component.id,
261
+ });
262
+ }
263
+ seen.add(component.id);
264
+
265
+ if (component.kind === 'unsupported') {
266
+ issues.push({
267
+ code: 'unsupported-component',
268
+ severity: 'warning',
269
+ message: `${component.id}: unsupported source type ${component.sourceTypeName ?? 'unknown'}`,
270
+ componentId: component.id,
271
+ });
272
+ continue;
273
+ }
274
+
275
+ for (const issue of validateComponent(component)) {
276
+ issues.push(issue);
277
+ }
278
+
279
+ for (const issue of validateSemanticMetadata(component)) {
280
+ issues.push(issue);
281
+ }
282
+ }
283
+
284
+ for (const wire of doc.wires) {
285
+ const [a, b] = wire.endpoints;
286
+ if (a.x === b.x && a.y === b.y) {
287
+ issues.push({
288
+ code: 'degenerate-wire',
289
+ severity: 'warning',
290
+ message: `Wire "${wire.id}" has identical endpoints`,
291
+ wireId: wire.id,
292
+ });
293
+ }
294
+ }
295
+
296
+ for (const issue of validateDeviceInterface(doc, seen)) {
297
+ issues.push(issue);
298
+ }
299
+
300
+ for (const issue of validatePanel(doc, seen, new Set(doc.deviceInterface?.controls.map((control) => control.id) ?? []))) {
301
+ issues.push(issue);
302
+ }
303
+
304
+ return issues;
305
+ }
306
+
307
+ export function hasErrors(issues: readonly ValidationIssue[]): boolean {
308
+ return issues.some((issue) => issue.severity === 'error');
309
+ }
310
+
311
+ function isRequirementWaived(component: Component, rule: PropertyRule): boolean {
312
+ if (isInterfaceOnlyComponent(component)) {
313
+ return true;
314
+ }
315
+
316
+ // Only the "model" string requirement has a waiver path today.
317
+ if (rule.kind !== 'string' || rule.name !== 'model') {
318
+ return false;
319
+ }
320
+ if (component.kind === 'ic' && component.properties.RuntimeDescriptor === 'true') {
321
+ return true;
322
+ }
323
+ const shortType = shortSourceType(component.sourceTypeName);
324
+ if (shortType !== null && IDEAL_SOURCE_TYPES.has(shortType)) {
325
+ return true;
326
+ }
327
+ const inline = INLINE_MODEL_PARAMETERS[component.kind] ?? [];
328
+ return inline.some((name) => component.properties[name] !== undefined);
329
+ }
330
+
331
+ function isInterfaceOnlyComponent(component: Component): boolean {
332
+ const interfaceOnly = component.properties.InterfaceOnly;
333
+ if (interfaceOnly === true) {
334
+ return true;
335
+ }
336
+ if (typeof interfaceOnly === 'string' && normalizeToken(interfaceOnly) === 'true') {
337
+ return true;
338
+ }
339
+ const support = component.properties.Support;
340
+ return typeof support === 'string' && normalizeToken(support) === 'view-only';
341
+ }
342
+
343
+ function validateSemanticMetadata(component: Component): readonly ValidationIssue[] {
344
+ const issues: ValidationIssue[] = [];
345
+
346
+ if (component.kind === 'jack') {
347
+ issues.push(...validateJackSemanticMetadata(component));
348
+ }
349
+
350
+ if (component.kind === 'ic' && component.properties.RuntimeDescriptor === 'true') {
351
+ issues.push(...validateRuntimeDescriptorMetadata(component));
352
+ }
353
+
354
+ return issues;
355
+ }
356
+
357
+ function validateJackSemanticMetadata(component: Component): readonly ValidationIssue[] {
358
+ const issues: ValidationIssue[] = [];
359
+
360
+ for (const property of ['Role', 'ControlRole'] as const) {
361
+ const value = propertyString(component, property);
362
+ if (value !== null && value.trim().length > 0 && !isRecognizedJackRole(value)) {
363
+ issues.push({
364
+ code: 'invalid-jack-role',
365
+ severity: 'warning',
366
+ message: `${component.id}: jack ${property} "${value}" is not a recognized panel role`,
367
+ componentId: component.id,
368
+ property,
369
+ });
370
+ }
371
+ }
372
+
373
+ const interfaceName = propertyString(component, 'Interface');
374
+ if (interfaceName !== null && interfaceName.trim().length > 0 && !isRecognizedJackInterface(interfaceName)) {
375
+ issues.push({
376
+ code: 'invalid-jack-interface',
377
+ severity: 'warning',
378
+ message: `${component.id}: jack Interface "${interfaceName}" is not a recognized panel interface`,
379
+ componentId: component.id,
380
+ property: 'Interface',
381
+ });
382
+ }
383
+
384
+ const audioRole = propertyString(component, 'AudioRole');
385
+ if (audioRole !== null && !isValidJackAudioRole(audioRole)) {
386
+ issues.push({
387
+ code: 'invalid-jack-audio-role',
388
+ severity: 'warning',
389
+ message: `${component.id}: jack AudioRole "${audioRole}" must be a lower-kebab source subtype slug`,
390
+ componentId: component.id,
391
+ property: 'AudioRole',
392
+ });
393
+ }
394
+
395
+ return issues;
396
+ }
397
+
398
+ function validateRuntimeDescriptorMetadata(component: Component): readonly ValidationIssue[] {
399
+ const issues: ValidationIssue[] = [];
400
+
401
+ for (const property of RUNTIME_DESCRIPTOR_CONTROL_PROPERTIES) {
402
+ const value = propertyString(component, property);
403
+ if (value !== null && value.trim().length === 0) {
404
+ issues.push({
405
+ code: 'descriptor-control-empty',
406
+ severity: 'warning',
407
+ message: `${component.id}: runtime descriptor property "${property}" must not be empty`,
408
+ componentId: component.id,
409
+ property,
410
+ });
411
+ }
412
+ }
413
+
414
+ const labels = parseStringList(propertyStringAny(component, ['ModeLabels', 'ModeOptions']));
415
+ const stepCount = parsePositiveInteger(propertyStringAny(component, ['ModeStepCount', 'ModeSteps', 'ModeCount']));
416
+ if (labels.length > 0 && stepCount !== undefined && labels.length !== stepCount) {
417
+ issues.push({
418
+ code: 'descriptor-mode-label-mismatch',
419
+ severity: 'warning',
420
+ message: `${component.id}: ModeLabels has ${labels.length} labels but ModeStepCount is ${stepCount}`,
421
+ componentId: component.id,
422
+ property: 'ModeLabels',
423
+ });
424
+ }
425
+
426
+ return issues;
427
+ }
428
+
429
+ function shortSourceType(sourceTypeName: string | null): string | null {
430
+ if (sourceTypeName === null) {
431
+ return null;
432
+ }
433
+ const head = sourceTypeName.split(',')[0]?.trim() ?? '';
434
+ if (head.length === 0) {
435
+ return null;
436
+ }
437
+ const lastDot = head.lastIndexOf('.');
438
+ return lastDot >= 0 ? head.slice(lastDot + 1) : head;
439
+ }
440
+
441
+ function findProperty(component: Component, rule: PropertyRule): PropertyValue | undefined {
442
+ const candidates = [rule.name, ...(rule.aliases ?? [])];
443
+ for (const name of candidates) {
444
+ const value = component.properties[name];
445
+ if (value !== undefined) {
446
+ return value;
447
+ }
448
+ }
449
+ return undefined;
450
+ }
451
+
452
+ function propertyString(component: Component, name: string): string | null {
453
+ return propertyStringValue(component.properties[name]);
454
+ }
455
+
456
+ function propertyStringAny(component: Component, names: readonly string[]): string | null {
457
+ for (const name of names) {
458
+ const value = propertyString(component, name);
459
+ if (value !== null) {
460
+ return value;
461
+ }
462
+ }
463
+ return null;
464
+ }
465
+
466
+ function coerceQuantity(value: PropertyValue): ParsedQuantity | null {
467
+ return propertyQuantityValue(value);
468
+ }
469
+
470
+ function isRawQuantityExpression(value: string): boolean {
471
+ const trimmed = value.trim();
472
+ if (trimmed.length === 0) {
473
+ return false;
474
+ }
475
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
476
+ return true;
477
+ }
478
+ return /^(AC|DC)\b/i.test(trimmed) ||
479
+ /^(SINE|PULSE|PWL|EXP|SFFM|AM|WAVEFILE)\s*\(/i.test(trimmed);
480
+ }
481
+
482
+ function isRecognizedJackRole(value: string): boolean {
483
+ const normalized = normalizeToken(value);
484
+ return [
485
+ 'input',
486
+ 'audio-input',
487
+ 'in',
488
+ 'direct-output',
489
+ 'direct-out',
490
+ 'dry-output',
491
+ 'dry-out',
492
+ 'output',
493
+ 'audio-output',
494
+ 'out',
495
+ 'send',
496
+ 'return',
497
+ 'expression',
498
+ 'exp',
499
+ 'expression-pedal',
500
+ 'tempo-tap',
501
+ 'tap-tempo',
502
+ 'tempo-in',
503
+ 'tap',
504
+ 'tempo',
505
+ 'external-control',
506
+ 'external-control-input',
507
+ 'control-input',
508
+ 'remote',
509
+ 'footswitch',
510
+ 'trigger',
511
+ 'reset',
512
+ ].includes(normalized);
513
+ }
514
+
515
+ function isRecognizedJackInterface(value: string): boolean {
516
+ const normalized = normalizeToken(value);
517
+ return isRecognizedJackRole(value) ||
518
+ [
519
+ 'audio',
520
+ 'audio-port',
521
+ 'control',
522
+ 'control-port',
523
+ 'tap-tempo-input',
524
+ ].includes(normalized);
525
+ }
526
+
527
+ function isValidJackAudioRole(value: string): boolean {
528
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
529
+ }
530
+
531
+ function parseStringList(value: string | null): readonly string[] {
532
+ if (value === null) {
533
+ return [];
534
+ }
535
+ return value
536
+ .split(/[,;|]/)
537
+ .map((part) => part.trim())
538
+ .filter((part) => part.length > 0);
539
+ }
540
+
541
+ function parsePositiveInteger(value: string | null): number | undefined {
542
+ if (value === null) {
543
+ return undefined;
544
+ }
545
+ const trimmed = value.trim();
546
+ if (!/^\d+(?:\.0+)?$/.test(trimmed)) {
547
+ return undefined;
548
+ }
549
+ const count = Number(trimmed);
550
+ return Number.isInteger(count) && count > 0 ? count : undefined;
551
+ }
552
+
553
+ function normalizeToken(value: string): string {
554
+ return value.trim().toLowerCase().replace(/[\s_]+/g, '-');
555
+ }
556
+
557
+ function validateDeviceInterface(
558
+ doc: CircuitDocument,
559
+ componentIds: ReadonlySet<string>,
560
+ ): readonly ValidationIssue[] {
561
+ const issues: ValidationIssue[] = [];
562
+ const groupIds = new Set(doc.controlGroups?.map((group) => group.id) ?? []);
563
+ const contextIds = new Set(doc.controlContexts?.map((context) => context.id) ?? []);
564
+ const semanticControlIds = new Set<string>();
565
+ const externalInterfaceIds = new Set(doc.controlInterfaces?.map((controlInterface) => controlInterface.id) ?? []);
566
+ const componentsById = new Map(doc.components.map((component) => [component.id, component]));
567
+ const resolvedPanelElements = resolvePanelElements(doc);
568
+
569
+ for (const group of doc.controlGroups ?? []) {
570
+ issues.push(...validateOpenToken(group.role, group.id, 'role'));
571
+ for (const contextId of group.contextIds ?? []) {
572
+ if (!contextIds.has(contextId)) {
573
+ issues.push({
574
+ code: 'control-group-context-unresolved',
575
+ severity: 'warning',
576
+ message: `Control group "${group.id}" references missing context "${contextId}"`,
577
+ componentId: group.id,
578
+ property: 'contextIds',
579
+ });
580
+ }
581
+ }
582
+ }
583
+
584
+ for (const context of doc.controlContexts ?? []) {
585
+ issues.push(...validateOpenToken(context.role, context.id, 'role'));
586
+ }
587
+
588
+ for (const control of doc.deviceInterface?.controls ?? []) {
589
+ if (semanticControlIds.has(control.id)) {
590
+ issues.push({
591
+ code: 'duplicate-device-interface-control-id',
592
+ severity: 'error',
593
+ message: `Duplicate device interface control id "${control.id}"`,
594
+ componentId: control.id,
595
+ });
596
+ }
597
+ semanticControlIds.add(control.id);
598
+
599
+ issues.push(...validateOpenToken(control.role, control.id, 'role'));
600
+
601
+ if (control.groupId !== undefined && !groupIds.has(control.groupId)) {
602
+ issues.push({
603
+ code: 'device-interface-group-unresolved',
604
+ severity: 'warning',
605
+ message: `Device interface control "${control.id}" references missing group "${control.groupId}"`,
606
+ componentId: control.id,
607
+ property: 'groupId',
608
+ });
609
+ }
610
+
611
+ issues.push(...validateApplicability(control, contextIds));
612
+
613
+ if (control.binding !== undefined) {
614
+ issues.push(...validateDeviceInterfaceBinding(
615
+ control,
616
+ control.binding,
617
+ componentIds,
618
+ externalInterfaceIds,
619
+ componentsById,
620
+ resolvedPanelElements,
621
+ ));
622
+ }
623
+ }
624
+
625
+ issues.push(...validateDuplicateDeviceInterfaceRoles(doc.deviceInterface?.controls ?? []));
626
+
627
+ return issues;
628
+ }
629
+
630
+ function validateOpenToken(value: string, componentId: string, property: string): readonly ValidationIssue[] {
631
+ if (/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value)) {
632
+ return [];
633
+ }
634
+ return [{
635
+ code: 'invalid-device-interface-token',
636
+ severity: 'warning',
637
+ message: `${componentId}: ${property} "${value}" must be a lower-kebab token`,
638
+ componentId,
639
+ property,
640
+ }];
641
+ }
642
+
643
+ function validateApplicability(
644
+ control: DeviceInterfaceControl,
645
+ contextIds: ReadonlySet<string>,
646
+ ): readonly ValidationIssue[] {
647
+ const issues: ValidationIssue[] = [];
648
+ if (control.appliesWhen === undefined) {
649
+ return issues;
650
+ }
651
+
652
+ issues.push(...validateContextList(control.id, 'appliesWhen.allOf', control.appliesWhen.allOf, contextIds));
653
+ issues.push(...validateContextList(control.id, 'appliesWhen.anyOf', control.appliesWhen.anyOf, contextIds));
654
+
655
+ if (
656
+ control.appliesWhen.allOf !== undefined
657
+ && control.appliesWhen.allOf.length === 0
658
+ && control.appliesWhen.anyOf === undefined
659
+ ) {
660
+ issues.push(emptyApplicabilityIssue(control.id, 'appliesWhen.allOf'));
661
+ }
662
+ if (
663
+ control.appliesWhen.anyOf !== undefined
664
+ && control.appliesWhen.anyOf.length === 0
665
+ && control.appliesWhen.allOf === undefined
666
+ ) {
667
+ issues.push(emptyApplicabilityIssue(control.id, 'appliesWhen.anyOf'));
668
+ }
669
+
670
+ return issues;
671
+ }
672
+
673
+ function validateContextList(
674
+ controlId: string,
675
+ property: string,
676
+ values: readonly string[] | undefined,
677
+ contextIds: ReadonlySet<string>,
678
+ ): readonly ValidationIssue[] {
679
+ if (values === undefined) {
680
+ return [];
681
+ }
682
+
683
+ const issues: ValidationIssue[] = [];
684
+ const seen = new Set<string>();
685
+ if (values.length === 0) {
686
+ issues.push(emptyApplicabilityIssue(controlId, property));
687
+ }
688
+
689
+ for (const contextId of values) {
690
+ if (seen.has(contextId)) {
691
+ issues.push({
692
+ code: 'device-interface-context-unresolved',
693
+ severity: 'warning',
694
+ message: `Device interface control "${controlId}" repeats context "${contextId}" in ${property}`,
695
+ componentId: controlId,
696
+ property,
697
+ });
698
+ }
699
+ seen.add(contextId);
700
+
701
+ if (!contextIds.has(contextId)) {
702
+ issues.push({
703
+ code: 'device-interface-context-unresolved',
704
+ severity: 'warning',
705
+ message: `Device interface control "${controlId}" references missing context "${contextId}"`,
706
+ componentId: controlId,
707
+ property,
708
+ });
709
+ }
710
+ }
711
+
712
+ return issues;
713
+ }
714
+
715
+ function emptyApplicabilityIssue(controlId: string, property: string): ValidationIssue {
716
+ return {
717
+ code: 'device-interface-context-unresolved',
718
+ severity: 'warning',
719
+ message: `Device interface control "${controlId}" has empty ${property}; omit the predicate instead`,
720
+ componentId: controlId,
721
+ property,
722
+ };
723
+ }
724
+
725
+ function validateDeviceInterfaceBinding(
726
+ control: DeviceInterfaceControl,
727
+ binding: DeviceInterfaceBinding,
728
+ componentIds: ReadonlySet<string>,
729
+ externalInterfaceIds: ReadonlySet<string>,
730
+ componentsById: ReadonlyMap<string, Component>,
731
+ resolvedPanelElements: readonly ResolvedPanelElement[],
732
+ ): readonly ValidationIssue[] {
733
+ const issues: ValidationIssue[] = [];
734
+ if (binding.externalInterfaceId !== undefined && !externalInterfaceIds.has(binding.externalInterfaceId)) {
735
+ issues.push({
736
+ code: 'device-interface-binding-unresolved',
737
+ severity: 'warning',
738
+ message: `Device interface control "${control.id}" references missing external interface "${binding.externalInterfaceId}"`,
739
+ componentId: control.id,
740
+ property: 'binding.externalInterfaceId',
741
+ });
742
+ }
743
+
744
+ if (!componentIds.has(binding.componentId)) {
745
+ issues.push({
746
+ code: 'device-interface-binding-unresolved',
747
+ severity: 'warning',
748
+ message: `Device interface control "${control.id}" references missing component "${binding.componentId}"`,
749
+ componentId: control.id,
750
+ property: 'binding.componentId',
751
+ });
752
+ return issues;
753
+ }
754
+
755
+ if (
756
+ binding.controlId !== undefined
757
+ && !resolvedPanelElements.some((resolved) =>
758
+ resolved.componentId === binding.componentId && resolved.id === binding.controlId
759
+ )
760
+ ) {
761
+ issues.push({
762
+ code: 'device-interface-binding-unresolved',
763
+ severity: 'warning',
764
+ message: `Device interface control "${control.id}" references missing control "${binding.controlId}"`,
765
+ componentId: control.id,
766
+ property: 'binding.controlId',
767
+ });
768
+ }
769
+
770
+ const component = componentsById.get(binding.componentId);
771
+ if (binding.property !== undefined && component?.properties[binding.property] === undefined) {
772
+ issues.push({
773
+ code: 'device-interface-binding-unresolved',
774
+ severity: 'warning',
775
+ message: `Device interface control "${control.id}" references missing property "${binding.property}"`,
776
+ componentId: control.id,
777
+ property: 'binding.property',
778
+ });
779
+ }
780
+
781
+ return issues;
782
+ }
783
+
784
+ function validateDuplicateDeviceInterfaceRoles(
785
+ controls: readonly DeviceInterfaceControl[],
786
+ ): readonly ValidationIssue[] {
787
+ const issues: ValidationIssue[] = [];
788
+ const seen = new Map<string, DeviceInterfaceControl>();
789
+ for (const control of controls) {
790
+ const key = `${control.groupId ?? ''}:${control.role}`;
791
+ const existing = seen.get(key);
792
+ if (existing !== undefined && existing.order === undefined && control.order === undefined) {
793
+ if (deviceInterfaceBindingSignature(existing.binding) === deviceInterfaceBindingSignature(control.binding)) {
794
+ issues.push({
795
+ code: 'device-interface-duplicate-role',
796
+ severity: 'warning',
797
+ message: `Device interface controls "${existing.id}" and "${control.id}" share role "${control.role}" without order or distinct binding`,
798
+ componentId: control.id,
799
+ property: 'role',
800
+ });
801
+ }
802
+ }
803
+ seen.set(key, control);
804
+ }
805
+ return issues;
806
+ }
807
+
808
+ function deviceInterfaceBindingSignature(binding: DeviceInterfaceBinding | undefined): string {
809
+ if (binding === undefined) {
810
+ return '';
811
+ }
812
+ return [
813
+ binding.componentId,
814
+ binding.controlId ?? '',
815
+ binding.controlName ?? '',
816
+ binding.property ?? '',
817
+ binding.externalInterfaceId ?? '',
818
+ ].join(':');
819
+ }
820
+
821
+ function validatePanel(
822
+ doc: CircuitDocument,
823
+ componentIds: ReadonlySet<string>,
824
+ semanticControlIds: ReadonlySet<string>,
825
+ ): readonly ValidationIssue[] {
826
+ if (doc.panel === undefined) {
827
+ return [];
828
+ }
829
+
830
+ const issues: ValidationIssue[] = [];
831
+ const resolvedElements = resolvePanelElements(doc);
832
+
833
+ for (const face of doc.panel.faces) {
834
+ for (const element of face.elements) {
835
+ const componentId = element.bind.componentId;
836
+ if (element.interfaceControlId !== undefined && !semanticControlIds.has(element.interfaceControlId)) {
837
+ issues.push({
838
+ code: 'panel-interface-control-unresolved',
839
+ severity: 'warning',
840
+ message: `Panel element on face "${face.id}" references missing interface control "${element.interfaceControlId}"`,
841
+ componentId: element.interfaceControlId,
842
+ property: 'interfaceControlId',
843
+ });
844
+ }
845
+ if (!componentIds.has(componentId)) {
846
+ issues.push({
847
+ code: 'panel-binding-unresolved',
848
+ severity: 'warning',
849
+ message: `Panel element on face "${face.id}" references missing component "${componentId}"`,
850
+ componentId,
851
+ });
852
+ continue;
853
+ }
854
+
855
+ const resolved = resolvePanelElement(resolvedElements, element);
856
+ if (element.bind.controlId !== undefined && resolved === undefined) {
857
+ issues.push({
858
+ code: 'panel-control-unresolved',
859
+ severity: 'warning',
860
+ message: `Panel element on face "${face.id}" references missing control "${element.bind.controlId}" on component "${componentId}"`,
861
+ componentId,
862
+ property: element.bind.controlId,
863
+ });
864
+ continue;
865
+ }
866
+
867
+ if (resolved !== undefined && resolved.kind !== element.kind) {
868
+ issues.push({
869
+ code: 'panel-kind-mismatch',
870
+ severity: 'warning',
871
+ message: `Panel element on face "${face.id}" binds component "${componentId}" as ${element.kind} but resolved kind is ${resolved.kind}`,
872
+ componentId,
873
+ });
874
+ }
875
+ }
876
+
877
+ for (const issue of validatePanelCellCollisions(face)) {
878
+ issues.push(issue);
879
+ }
880
+ }
881
+
882
+ return issues;
883
+ }
884
+
885
+ function resolvePanelElements(doc: CircuitDocument): readonly ResolvedPanelElement[] {
886
+ const panel = extractPanel(doc);
887
+ const resolved: ResolvedPanelElement[] = [];
888
+
889
+ for (const knob of panel.knobs) {
890
+ resolved.push({
891
+ id: knob.id,
892
+ componentId: componentIdFromPanelElementId(knob.id),
893
+ kind: knob.id.endsWith(':mode') && knob.controlMode === 'stepped' ? 'switch' : 'knob',
894
+ });
895
+ }
896
+ for (const slider of panel.sliders ?? []) {
897
+ resolved.push({
898
+ id: slider.id,
899
+ componentId: componentIdFromPanelElementId(slider.id),
900
+ kind: 'slider',
901
+ });
902
+ }
903
+ for (const switchControl of panel.switches) {
904
+ resolved.push({
905
+ id: switchControl.id,
906
+ componentId: componentIdFromPanelElementId(switchControl.id),
907
+ kind: 'switch',
908
+ });
909
+ }
910
+ for (const led of panel.leds) {
911
+ resolved.push({
912
+ id: led.id,
913
+ componentId: componentIdFromPanelElementId(led.id),
914
+ kind: 'led',
915
+ });
916
+ }
917
+ for (const jack of panel.jacks) {
918
+ resolved.push({
919
+ id: jack.id,
920
+ componentId: jack.sourceComponentId ?? componentIdFromPanelElementId(jack.id),
921
+ kind: 'jack',
922
+ });
923
+ }
924
+
925
+ return resolved;
926
+ }
927
+
928
+ function resolvePanelElement(
929
+ resolvedElements: readonly ResolvedPanelElement[],
930
+ element: PanelElementPlacement,
931
+ ): ResolvedPanelElement | undefined {
932
+ if (element.bind.controlId !== undefined) {
933
+ return resolvedElements.find((resolved) =>
934
+ resolved.componentId === element.bind.componentId && resolved.id === element.bind.controlId,
935
+ );
936
+ }
937
+
938
+ return resolvedElements.find((resolved) =>
939
+ resolved.componentId === element.bind.componentId && resolved.id === element.bind.componentId,
940
+ );
941
+ }
942
+
943
+ function componentIdFromPanelElementId(id: string): string {
944
+ const separator = id.indexOf(':');
945
+ return separator <= 0 ? id : id.slice(0, separator);
946
+ }
947
+
948
+ function validatePanelCellCollisions(face: PanelFace): readonly ValidationIssue[] {
949
+ const issues: ValidationIssue[] = [];
950
+ const occupied = new Map<string, PanelElementPlacement>();
951
+
952
+ for (const element of face.elements) {
953
+ const rowSpan = element.grid.rowSpan ?? 1;
954
+ const columnSpan = element.grid.columnSpan ?? 1;
955
+ for (let rowOffset = 0; rowOffset < rowSpan; rowOffset += 1) {
956
+ for (let columnOffset = 0; columnOffset < columnSpan; columnOffset += 1) {
957
+ const row = element.grid.row + rowOffset;
958
+ const column = element.grid.column + columnOffset;
959
+ const key = `${row}:${column}`;
960
+ if (occupied.has(key)) {
961
+ issues.push({
962
+ code: 'panel-cell-collision',
963
+ severity: 'warning',
964
+ message: `Panel face "${face.id}" has overlapping elements at row ${row}, column ${column}`,
965
+ componentId: element.bind.componentId,
966
+ });
967
+ continue;
968
+ }
969
+ occupied.set(key, element);
970
+ }
971
+ }
972
+ }
973
+
974
+ return issues;
975
+ }
976
+
977
+ function missingPropertyIssue(component: Component, rule: PropertyRule): ValidationIssue {
978
+ return {
979
+ code: rule.kind === 'string' ? 'model-required' : 'value-required',
980
+ severity: 'error',
981
+ message: `${component.id} (${component.kind}): missing required property "${rule.name}"`,
982
+ componentId: component.id,
983
+ property: rule.name,
984
+ };
985
+ }