@vessel-dsp/stompbox 0.6.4

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 (63) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +471 -0
  3. package/assets/cad/parts/box-125b/.tayda-a7244.step.glb +0 -0
  4. package/assets/cad/parts/box-125b/tayda-a7244.step +3588 -0
  5. package/assets/cad/parts/box-1590b/.tayda-a6619.step.glb +0 -0
  6. package/assets/cad/parts/box-1590b/tayda-a6619.step +3587 -0
  7. package/assets/cad/parts/box-1590bb/.tayda-a5880.step.glb +0 -0
  8. package/assets/cad/parts/box-1590bb/tayda-a5880.step +3589 -0
  9. package/assets/cad/parts/box-hammond-diecast-stompbox-series/.hammond-1590a.step.glb +0 -0
  10. package/assets/cad/parts/box-hammond-diecast-stompbox-series/.hammond-1590n1.step.glb +0 -0
  11. package/assets/cad/parts/box-hammond-diecast-stompbox-series/.hammond-1590xx.step.glb +0 -0
  12. package/assets/cad/parts/box-hammond-diecast-stompbox-series/hammond-1590a.step +3587 -0
  13. package/assets/cad/parts/box-hammond-diecast-stompbox-series/hammond-1590n1.step +3589 -0
  14. package/assets/cad/parts/box-hammond-diecast-stompbox-series/hammond-1590xx.step +3589 -0
  15. package/assets/cad/parts/dc-socket-dc099/.dc099.step.glb +0 -0
  16. package/assets/cad/parts/dc-socket-dc099/dc099.step +261 -0
  17. package/assets/cad/parts/jack-ts-pj629han/.pj-629han-05.step.glb +0 -0
  18. package/assets/cad/parts/jack-ts-pj629han/pj-629han-05.step +261 -0
  19. package/assets/cad/parts/knob-chickenhead-lms-30mm/.lovemyswitches-chicken-head-30mm.step.glb +0 -0
  20. package/assets/cad/parts/knob-chickenhead-lms-30mm/lovemyswitches-chicken-head-30mm.step +535 -0
  21. package/assets/cad/parts/knob-cm42-bb/.tayda-a6078-cm42-bb.step.glb +0 -0
  22. package/assets/cad/parts/knob-cm42-bb/tayda-a6078-cm42-bb.step +535 -0
  23. package/assets/cad/parts/knob-davies-instrument-series/.davies-1100.step.glb +0 -0
  24. package/assets/cad/parts/knob-davies-instrument-series/.davies-1105.step.glb +0 -0
  25. package/assets/cad/parts/knob-davies-instrument-series/.davies-1110.step.glb +0 -0
  26. package/assets/cad/parts/knob-davies-instrument-series/.davies-1120.step.glb +0 -0
  27. package/assets/cad/parts/knob-davies-instrument-series/.davies-1510bg.step.glb +0 -0
  28. package/assets/cad/parts/knob-davies-instrument-series/.davies-1550ag.step.glb +0 -0
  29. package/assets/cad/parts/knob-davies-instrument-series/.davies-1900.step.glb +0 -0
  30. package/assets/cad/parts/knob-davies-instrument-series/davies-1100.step +7781 -0
  31. package/assets/cad/parts/knob-davies-instrument-series/davies-1105.step +7975 -0
  32. package/assets/cad/parts/knob-davies-instrument-series/davies-1110.step +8754 -0
  33. package/assets/cad/parts/knob-davies-instrument-series/davies-1120.step +8570 -0
  34. package/assets/cad/parts/knob-davies-instrument-series/davies-1510bg.step +1686 -0
  35. package/assets/cad/parts/knob-davies-instrument-series/davies-1550ag.step +3137 -0
  36. package/assets/cad/parts/knob-davies-instrument-series/davies-1900.step +19769 -0
  37. package/assets/cad/parts/knob-mxr-style-fluted/.daier-mf-b01.step.glb +0 -0
  38. package/assets/cad/parts/knob-mxr-style-fluted/.daier-mf-b02.step.glb +0 -0
  39. package/assets/cad/parts/knob-mxr-style-fluted/.daier-mf-b03.step.glb +0 -0
  40. package/assets/cad/parts/knob-mxr-style-fluted/.tayda-a1829-tymf-b00.step.glb +0 -0
  41. package/assets/cad/parts/knob-mxr-style-fluted/daier-mf-b01.step +4277 -0
  42. package/assets/cad/parts/knob-mxr-style-fluted/daier-mf-b02.step +4318 -0
  43. package/assets/cad/parts/knob-mxr-style-fluted/daier-mf-b03.step +4577 -0
  44. package/assets/cad/parts/knob-mxr-style-fluted/tayda-a1829-tymf-b00.step +535 -0
  45. package/assets/cad/parts/led-5mm-red-kento-5408urc/.kento-5408urc.step.glb +0 -0
  46. package/assets/cad/parts/led-5mm-red-kento-5408urc/kento-5408urc.step +384 -0
  47. package/assets/cad/parts/led-bezel-lh5/.pedal-parts-and-kits-bzl-5mm-p.step.glb +0 -0
  48. package/assets/cad/parts/led-bezel-lh5/pedal-parts-and-kits-bzl-5mm-p.step +469 -0
  49. package/assets/cad/parts/switch-3pdt-pic-pbs24302/.pic-pbs24302.step.glb +0 -0
  50. package/assets/cad/parts/switch-3pdt-pic-pbs24302/pic-pbs24302.step +435 -0
  51. package/assets/cad/parts/switch-toggle-reference-series/.toggle-switch-dpdt-pcb.step.glb +0 -0
  52. package/assets/cad/parts/switch-toggle-reference-series/.toggle-switch-mounting-plate.step.glb +0 -0
  53. package/assets/cad/parts/switch-toggle-reference-series/.toggle-switch-spdt-pcb.step.glb +0 -0
  54. package/assets/cad/parts/switch-toggle-reference-series/.toggle-switch-spst-13x7.step.glb +0 -0
  55. package/assets/cad/parts/switch-toggle-reference-series/toggle-switch-dpdt-pcb.step +2860 -0
  56. package/assets/cad/parts/switch-toggle-reference-series/toggle-switch-mounting-plate.step +1090 -0
  57. package/assets/cad/parts/switch-toggle-reference-series/toggle-switch-spdt-pcb.step +1749 -0
  58. package/assets/cad/parts/switch-toggle-reference-series/toggle-switch-spst-13x7.step +1608 -0
  59. package/dist/index.d.ts +714 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +4462 -0
  62. package/dist/index.js.map +1 -0
  63. package/package.json +55 -0
package/dist/index.js ADDED
@@ -0,0 +1,4462 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { defaultControlState, extractPanel, parseCircuitDocumentFile, } from "@vessel-dsp/core";
3
+ export function getAvailableStompboxStyleProfiles(profiles, filter) {
4
+ return profiles.filter((profile) => profile.supportedKnobCounts.includes(filter.knobCount));
5
+ }
6
+ export const STOMPBOX_DRILL_HOLE_PROFILE_CATALOG = {
7
+ "dc-jack-3pdt-1-2": {
8
+ id: "dc-jack-3pdt-1-2",
9
+ label: "DC Jack / 3PDT",
10
+ diameterMm: 12.7,
11
+ fractionInches: '1/2"',
12
+ marker: "ring-with-center-dot",
13
+ },
14
+ "audio-jack-24mm-pot-3-8": {
15
+ id: "audio-jack-24mm-pot-3-8",
16
+ label: "Audio Jacks / 24mm Pots",
17
+ diameterMm: 9.525,
18
+ fractionInches: '3/8"',
19
+ marker: "ring-with-center-dot",
20
+ },
21
+ "metal-5mm-led-bezel-5-16": {
22
+ id: "metal-5mm-led-bezel-5-16",
23
+ label: "Metal 5mm LED Bezel",
24
+ diameterMm: 7.9375,
25
+ fractionInches: '5/16"',
26
+ marker: "ring-with-center-dot",
27
+ },
28
+ "sixteen-mm-pot-9-32": {
29
+ id: "sixteen-mm-pot-9-32",
30
+ label: "16mm Pots",
31
+ diameterMm: 7.14375,
32
+ fractionInches: '9/32"',
33
+ marker: "ring-with-center-dot",
34
+ },
35
+ "mini-toggle-switch-1-4": {
36
+ id: "mini-toggle-switch-1-4",
37
+ label: "Mini Toggle Switch",
38
+ diameterMm: 6.35,
39
+ fractionInches: '1/4"',
40
+ marker: "ring-with-center-dot",
41
+ },
42
+ "five-mm-led-13-64": {
43
+ id: "five-mm-led-13-64",
44
+ label: "5mm LED",
45
+ diameterMm: 5.159375,
46
+ fractionInches: '13/64"',
47
+ marker: "ring-with-center-dot",
48
+ },
49
+ "three-mm-led-1-8": {
50
+ id: "three-mm-led-1-8",
51
+ label: "3mm LED",
52
+ diameterMm: 3.175,
53
+ fractionInches: '1/8"',
54
+ marker: "ring-with-center-dot",
55
+ },
56
+ "pilot-hole-1-16": {
57
+ id: "pilot-hole-1-16",
58
+ label: "Pilot Hole",
59
+ diameterMm: 1.5875,
60
+ fractionInches: '1/16"',
61
+ marker: "center-dot",
62
+ },
63
+ };
64
+ const STOMPBOX_GRID_MIN_CELL_MM = 12;
65
+ const STOMPBOX_GRID_EDGE_MARGIN_MM = 1;
66
+ const STOMPBOX_GRID_TARGET_ROW_PITCH_MM = 20;
67
+ const STOMPBOX_EDGE_ROTATED_SIDE_LABEL_GAP_MM = 4;
68
+ const STOMPBOX_LARGE_KNOB_DIAMETER_MM = 20;
69
+ const STOMPBOX_SMALL_KNOB_DIAMETER_MM = 14.5;
70
+ const STOMPBOX_LARGE_KNOB_MIN_PITCH_MM = 25;
71
+ const STOMPBOX_HOLE_BACKING_OUTSET_MM = 0.12;
72
+ const STOMPBOX_DC_JACK_HOLE_BACKING_INSET_MM = 0.7;
73
+ const STOMPBOX_DECAL_OUTSET_MM = 0.2;
74
+ const STOMPBOX_1590B_MIN_WIDTH_MM = 55;
75
+ const STOMPBOX_PREVIEW_SVG_GRAIN_BASE_FREQUENCY = 0.4;
76
+ const STOMPBOX_PREVIEW_SVG_GRAIN_NUM_OCTAVES = 10;
77
+ const STOMPBOX_PREVIEW_SVG_GRAIN_OPACITY = 0.15;
78
+ export function resolveStompboxAssetPaths(assets, options = {}) {
79
+ const base = options.baseUrl ?? options.basePath;
80
+ if (base === undefined || base.length === 0) {
81
+ return {
82
+ glb: assets.glbRelativePath,
83
+ step: assets.stepRelativePath,
84
+ };
85
+ }
86
+ return {
87
+ glb: joinAssetBase(base, assets.glbRelativePath),
88
+ step: joinAssetBase(base, assets.stepRelativePath),
89
+ };
90
+ }
91
+ export function validateStompboxGlbAsset(bytes, partProfile, options = {}) {
92
+ const assetPath = options.assetPath ?? partProfile.assets.glbRelativePath;
93
+ let parsed;
94
+ try {
95
+ parsed = parseGlbBytes(bytes, assetPath);
96
+ }
97
+ catch (error) {
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ const diagnostic = {
100
+ code: "invalid-glb-asset",
101
+ message: `Invalid GLB asset for stompbox part "${partProfile.id}": ${message}`,
102
+ partId: partProfile.id,
103
+ assetPath,
104
+ };
105
+ return {
106
+ schema: "stompbox-glb-asset-validation/v1",
107
+ partProfileId: partProfile.id,
108
+ assetPath,
109
+ valid: false,
110
+ targets: {},
111
+ diagnostics: [diagnostic],
112
+ };
113
+ }
114
+ const targets = {};
115
+ const diagnostics = [];
116
+ const candidates = glbStateTargetCandidates(parsed.json);
117
+ for (const required of requiredStateTargetsForPartProfile(partProfile)) {
118
+ const target = required.target;
119
+ if (target === undefined) {
120
+ diagnostics.push({
121
+ code: "missing-state-target-contract",
122
+ message: `Stompbox part "${partProfile.id}" requires live-state GLB target "${required.role}"`,
123
+ partId: partProfile.id,
124
+ assetPath,
125
+ targetRole: required.role,
126
+ });
127
+ continue;
128
+ }
129
+ const matches = candidates.filter((candidate) => stateTargetCandidateMatches(candidate, target.selector));
130
+ if (matches.length === 0) {
131
+ diagnostics.push({
132
+ code: "missing-state-target",
133
+ message: `GLB asset for stompbox part "${partProfile.id}" does not contain target "${required.role}"`,
134
+ partId: partProfile.id,
135
+ assetPath,
136
+ targetRole: required.role,
137
+ });
138
+ continue;
139
+ }
140
+ if (matches.length > 1) {
141
+ diagnostics.push({
142
+ code: "ambiguous-state-target",
143
+ message: `GLB asset for stompbox part "${partProfile.id}" matched ${matches.length} nodes for target "${required.role}"`,
144
+ partId: partProfile.id,
145
+ assetPath,
146
+ targetRole: required.role,
147
+ });
148
+ continue;
149
+ }
150
+ const match = matches[0];
151
+ if (match === undefined) {
152
+ continue;
153
+ }
154
+ targets[required.role] = {
155
+ role: required.role,
156
+ selector: target.selector,
157
+ nodeName: match.nodeName,
158
+ ...(match.meshName === undefined ? {} : { meshName: match.meshName }),
159
+ ...(match.materialName === undefined
160
+ ? {}
161
+ : { materialName: match.materialName }),
162
+ ...(required.motion === undefined ? {} : required.motion),
163
+ };
164
+ }
165
+ return {
166
+ schema: "stompbox-glb-asset-validation/v1",
167
+ partProfileId: partProfile.id,
168
+ assetPath,
169
+ valid: diagnostics.length === 0,
170
+ targets,
171
+ diagnostics,
172
+ };
173
+ }
174
+ export function validateStompboxGlbAssetFile(path, partProfile) {
175
+ try {
176
+ return validateStompboxGlbAsset(new Uint8Array(readFileSync(path)), partProfile, {
177
+ assetPath: path,
178
+ });
179
+ }
180
+ catch (error) {
181
+ const message = error instanceof Error ? error.message : String(error);
182
+ const diagnostic = {
183
+ code: "invalid-glb-asset",
184
+ message: `Invalid GLB asset for stompbox part "${partProfile.id}": ${message}`,
185
+ partId: partProfile.id,
186
+ assetPath: path,
187
+ };
188
+ return {
189
+ schema: "stompbox-glb-asset-validation/v1",
190
+ partProfileId: partProfile.id,
191
+ assetPath: path,
192
+ valid: false,
193
+ targets: {},
194
+ diagnostics: [diagnostic],
195
+ };
196
+ }
197
+ }
198
+ export function validateStompboxHardwareProfileAssets(hardwareProfile, options = {}) {
199
+ const partIds = options.partIds ?? defaultLiveStatePartProfileIds(hardwareProfile);
200
+ const assets = {};
201
+ const diagnostics = [];
202
+ for (const partId of uniqueStrings(partIds)) {
203
+ const partProfile = hardwareProfile.partProfiles[partId];
204
+ if (partProfile === undefined) {
205
+ const diagnostic = {
206
+ code: "unknown-part-profile",
207
+ message: `Unknown stompbox part profile "${partId}"`,
208
+ partId,
209
+ };
210
+ diagnostics.push(diagnostic);
211
+ continue;
212
+ }
213
+ const assetPath = resolveStompboxAssetPaths(partProfile.assets, options).glb;
214
+ const validation = validateStompboxGlbAssetFile(assetPath, partProfile);
215
+ assets[partId] = validation;
216
+ diagnostics.push(...validation.diagnostics);
217
+ }
218
+ return {
219
+ schema: "stompbox-hardware-profile-asset-validation/v1",
220
+ valid: diagnostics.length === 0,
221
+ assets,
222
+ diagnostics,
223
+ };
224
+ }
225
+ export function createStompboxSourcePanelControls(document) {
226
+ const componentsById = new Map(document.components.map((component) => [component.id, component]));
227
+ const controls = [];
228
+ for (const face of document.panel?.faces ?? []) {
229
+ for (const element of face.elements) {
230
+ if (element.kind !== "knob" &&
231
+ element.kind !== "switch" &&
232
+ element.kind !== "footswitch") {
233
+ continue;
234
+ }
235
+ const sourceComponentId = element.bind.componentId;
236
+ const component = sourceComponentId === undefined
237
+ ? undefined
238
+ : componentsById.get(sourceComponentId);
239
+ const label = nonEmptyText(element.label) ??
240
+ nonEmptyText(component?.name) ??
241
+ nonEmptyText(sourceComponentId) ??
242
+ nonEmptyText(element.id) ??
243
+ "Control";
244
+ const kind = element.kind === "knob" ? "knob" : "switch";
245
+ const sweep = sourcePanelControlPropertyText(component, "Sweep");
246
+ const options = sourcePanelControlOptions(component);
247
+ const description = sourcePanelControlPropertyText(component, "Description");
248
+ controls.push({
249
+ id: sourceComponentId ??
250
+ element.id ??
251
+ `${face.id}-${element.grid.row}-${element.grid.column}`,
252
+ label,
253
+ kind,
254
+ value: sourcePanelControlDefaultValue(kind, component),
255
+ ...(sourceComponentId === undefined ? {} : { sourceComponentId }),
256
+ ...(element.id === undefined ? {} : { panelElementId: element.id }),
257
+ ...(sweep === undefined ? {} : { sweep }),
258
+ ...(options === undefined ? {} : { options }),
259
+ ...(description === undefined ? {} : { description }),
260
+ });
261
+ }
262
+ }
263
+ return controls;
264
+ }
265
+ export function createStompboxControlSurface(document, options) {
266
+ const diagnostics = [];
267
+ const basePanel = extractPanel(document);
268
+ const panelControls = createStompboxSourcePanelControls(document);
269
+ const sourceControls = panelControls.length === 0
270
+ ? sourceControlsFromExtractedPanel(basePanel)
271
+ : panelControls;
272
+ const unmatchedCompiled = [...(options.compiledControls ?? [])];
273
+ const descriptors = [];
274
+ for (const sourceControl of sourceControls) {
275
+ const compiledIndex = findMatchingCompiledControlIndex(sourceControl, unmatchedCompiled, diagnostics);
276
+ const compiled = compiledIndex < 0
277
+ ? undefined
278
+ : unmatchedCompiled.splice(compiledIndex, 1)[0];
279
+ descriptors.push(runtimeControlDescriptor(sourceControl, compiled));
280
+ }
281
+ descriptors.push(...unmatchedCompiled.map((control) => runtimeControlDescriptor(undefined, control)));
282
+ const panel = panelFromRuntimeControls(basePanel, descriptors);
283
+ const routes = Object.fromEntries(descriptors.map((descriptor) => [
284
+ descriptor.id,
285
+ runtimeRouteForDescriptor(descriptor),
286
+ ]));
287
+ return {
288
+ schema: "stompbox-control-surface/v1",
289
+ pedalId: options.pedalId,
290
+ ...(options.label === undefined ? {} : { label: options.label }),
291
+ panel,
292
+ controls: descriptors,
293
+ routes,
294
+ diagnostics,
295
+ };
296
+ }
297
+ export function createStompboxPanelFromControlSurface(surface) {
298
+ return surface.panel;
299
+ }
300
+ export function createDefaultStompboxPedalState(document, options) {
301
+ const surface = createStompboxControlSurface(document, {
302
+ pedalId: options.pedalId,
303
+ ...(options.compiledControls === undefined
304
+ ? {}
305
+ : { compiledControls: options.compiledControls }),
306
+ });
307
+ return createStompboxPedalStateFromControlSurface(surface, {
308
+ ...(options.enabled === undefined ? {} : { enabled: options.enabled }),
309
+ });
310
+ }
311
+ export function createDefaultStompboxPedalStateFromVdsp(source, options) {
312
+ const document = parseCircuitDocumentFile(source, {
313
+ filename: options.filename ?? "stompbox.vdsp",
314
+ });
315
+ return createDefaultStompboxPedalState(document, options);
316
+ }
317
+ export function createStompboxPedalStateFromControlSurface(surface, options = {}) {
318
+ return {
319
+ schema: "stompbox-pedal-state/v1",
320
+ pedalId: surface.pedalId,
321
+ revision: 0,
322
+ enabled: options.enabled ?? false,
323
+ controls: defaultControlState(surface.panel),
324
+ };
325
+ }
326
+ export function normalizeStompboxControlValue(descriptor, rawValue) {
327
+ if (descriptor.kind === "switch") {
328
+ return {
329
+ kind: "switch",
330
+ position: switchPositionForRawValue(rawValue, descriptor),
331
+ };
332
+ }
333
+ return {
334
+ kind: "knob",
335
+ position: normalizeRawPosition(rawValue, descriptor.min, descriptor.max),
336
+ };
337
+ }
338
+ export function denormalizeStompboxControlValue(descriptor, value) {
339
+ if (descriptor.kind === "switch" && value.kind === "switch") {
340
+ return descriptor.min + value.position * Math.max(1, descriptor.step);
341
+ }
342
+ if (descriptor.kind === "knob" && value.kind === "knob") {
343
+ return (descriptor.min +
344
+ clamp01(value.position) * (descriptor.max - descriptor.min));
345
+ }
346
+ return descriptor.value;
347
+ }
348
+ export function createStompboxRuntimeCommand(surface, command) {
349
+ if (command.type === "noop") {
350
+ return { kind: "noop" };
351
+ }
352
+ if (command.type === "error") {
353
+ return {
354
+ kind: "error",
355
+ reason: command.reason,
356
+ ...(command.controlId === undefined
357
+ ? {}
358
+ : { controlId: command.controlId }),
359
+ };
360
+ }
361
+ if (command.type === "set-enabled") {
362
+ return {
363
+ kind: "set-enabled",
364
+ pedalId: surface.pedalId,
365
+ enabled: command.enabled,
366
+ };
367
+ }
368
+ const descriptor = surface.controls.find((control) => control.id === command.controlId);
369
+ if (descriptor === undefined) {
370
+ return {
371
+ kind: "error",
372
+ reason: `unknown control id "${command.controlId}"`,
373
+ controlId: command.controlId,
374
+ };
375
+ }
376
+ if (descriptor.runtimeControlId === undefined) {
377
+ return { kind: "noop" };
378
+ }
379
+ return {
380
+ kind: "set-control-value",
381
+ pedalId: surface.pedalId,
382
+ controlId: descriptor.runtimeControlId,
383
+ publicControlId: descriptor.id,
384
+ rawValue: denormalizeStompboxControlValue(descriptor, command.value),
385
+ };
386
+ }
387
+ export function setStompboxPedalEnabled(state, enabled) {
388
+ return applyStompboxPedalStateCommand(state, {
389
+ type: "set-enabled",
390
+ enabled,
391
+ });
392
+ }
393
+ export function setStompboxControlValue(state, controlId, value) {
394
+ return applyStompboxPedalStateCommand(state, {
395
+ type: "set-control-value",
396
+ controlId,
397
+ value,
398
+ });
399
+ }
400
+ export function applyStompboxPedalStateCommand(state, command) {
401
+ if (command.type === "noop" || command.type === "error") {
402
+ return state;
403
+ }
404
+ if (command.type === "set-enabled") {
405
+ if (state.enabled === command.enabled) {
406
+ return state;
407
+ }
408
+ return {
409
+ ...state,
410
+ enabled: command.enabled,
411
+ revision: state.revision + 1,
412
+ };
413
+ }
414
+ const current = state.controls[command.controlId];
415
+ if (sameControlValue(current, command.value)) {
416
+ return state;
417
+ }
418
+ return {
419
+ ...state,
420
+ revision: state.revision + 1,
421
+ controls: {
422
+ ...state.controls,
423
+ [command.controlId]: normalizedControlValue(command.value),
424
+ },
425
+ };
426
+ }
427
+ export function createStompboxKnobTurnCommand(surface, input) {
428
+ const descriptor = surface.controls.find((control) => control.id === input.controlId);
429
+ if (descriptor === undefined) {
430
+ return {
431
+ type: "error",
432
+ reason: `unknown control id "${input.controlId}"`,
433
+ controlId: input.controlId,
434
+ };
435
+ }
436
+ if (descriptor.kind !== "knob") {
437
+ return {
438
+ type: "error",
439
+ reason: `control "${input.controlId}" is not a knob`,
440
+ controlId: input.controlId,
441
+ };
442
+ }
443
+ return {
444
+ type: "set-control-value",
445
+ controlId: input.controlId,
446
+ value: { kind: "knob", position: clamp01(input.position) },
447
+ };
448
+ }
449
+ export function createStompboxFootswitchPressCommand(surface, input) {
450
+ if (input.partId === "switch-bypass" ||
451
+ input.controlId === "stompbox:enabled") {
452
+ return { type: "set-enabled", enabled: input.pressed };
453
+ }
454
+ const controlId = input.controlId ?? controlIdFromSwitchPartId(input.partId);
455
+ if (controlId === undefined) {
456
+ return {
457
+ type: "error",
458
+ reason: "footswitch press requires a controlId or partId",
459
+ };
460
+ }
461
+ const descriptor = surface.controls.find((control) => control.id === controlId);
462
+ if (descriptor === undefined) {
463
+ return {
464
+ type: "error",
465
+ reason: `unknown control id "${controlId}"`,
466
+ controlId,
467
+ };
468
+ }
469
+ if (descriptor.kind !== "switch") {
470
+ return {
471
+ type: "error",
472
+ reason: `control "${controlId}" is not a switch`,
473
+ controlId,
474
+ };
475
+ }
476
+ return {
477
+ type: "set-control-value",
478
+ controlId,
479
+ value: { kind: "switch", position: input.pressed ? 1 : 0 },
480
+ };
481
+ }
482
+ export function applyStompboxPreviewInteraction(state, command) {
483
+ return applyStompboxPedalStateCommand(state, command);
484
+ }
485
+ export function createStompboxPedalStateStore(initialState, options = {}) {
486
+ let state = initialState;
487
+ const listeners = new Set();
488
+ const controlListeners = new Map();
489
+ const patchListeners = new Set();
490
+ const notify = (previous, current, command) => {
491
+ const changedControlIds = changedControlIdsForState(previous, current);
492
+ const event = {
493
+ previous,
494
+ current,
495
+ changedControlIds,
496
+ enabledChanged: previous.enabled !== current.enabled,
497
+ command,
498
+ };
499
+ for (const listener of listeners) {
500
+ listener(event);
501
+ }
502
+ for (const controlId of changedControlIds) {
503
+ for (const listener of controlListeners.get(controlId) ?? []) {
504
+ listener(current.controls[controlId], event);
505
+ }
506
+ }
507
+ if (options.preview !== undefined && patchListeners.size > 0) {
508
+ const patch = createStompboxPreviewStatePatch(options.preview, current, previous);
509
+ if (Object.keys(patch.parts).length > 0) {
510
+ for (const listener of patchListeners) {
511
+ listener(patch, event);
512
+ }
513
+ }
514
+ }
515
+ };
516
+ const dispatch = (command) => {
517
+ const previous = state;
518
+ const current = applyStompboxPedalStateCommand(previous, command);
519
+ if (current !== previous) {
520
+ state = current;
521
+ notify(previous, current, command);
522
+ }
523
+ return state;
524
+ };
525
+ return {
526
+ getSnapshot: () => state,
527
+ dispatch,
528
+ setEnabled: (enabled) => dispatch({ type: "set-enabled", enabled }),
529
+ setControlValue: (controlId, value) => dispatch({ type: "set-control-value", controlId, value }),
530
+ turnKnob: (controlId, position) => dispatch({
531
+ type: "set-control-value",
532
+ controlId,
533
+ value: { kind: "knob", position: clamp01(position) },
534
+ }),
535
+ pressFootswitch: (partIdOrControlId, pressed) => dispatch(partIdOrControlId === "switch-bypass"
536
+ ? { type: "set-enabled", enabled: pressed }
537
+ : {
538
+ type: "set-control-value",
539
+ controlId: controlIdFromSwitchPartId(partIdOrControlId) ??
540
+ partIdOrControlId,
541
+ value: { kind: "switch", position: pressed ? 1 : 0 },
542
+ }),
543
+ subscribe: (listener) => subscribeSet(listeners, listener),
544
+ subscribeControl: (controlId, listener) => {
545
+ const listenersForControl = controlListeners.get(controlId) ??
546
+ new Set();
547
+ controlListeners.set(controlId, listenersForControl);
548
+ return subscribeSet(listenersForControl, listener);
549
+ },
550
+ subscribePreviewPatch: (listener) => subscribeSet(patchListeners, listener),
551
+ };
552
+ }
553
+ export function createStompboxDrillLayoutFromVdsp(source, options = {}) {
554
+ const document = parseCircuitDocumentFile(source, {
555
+ filename: options.filename ?? "stompbox.vdsp",
556
+ });
557
+ return createStompboxDrillLayout(document, options);
558
+ }
559
+ export function createStompboxDrillLayout(document, options = {}) {
560
+ const hardwareProfile = requireStompboxHardwareProfile(options);
561
+ const enclosure = enclosureProfile(options.enclosureId, hardwareProfile);
562
+ const placementStyle = resolveStompboxPlacementStyle(options.styleProfile, hardwareProfile);
563
+ const panel = extractPanel(document);
564
+ const controlMetadata = controlMetadataById(panel);
565
+ const diagnostics = [];
566
+ const declared = declaredPhysicalPlacements(document.panel?.faces ?? [], controlMetadata, hardwareProfile, hardwareProfile.defaultPartIds, diagnostics);
567
+ const gridDeclared = gridPhysicalPlacements(document.panel?.faces ?? [], controlMetadata, enclosure, hardwareProfile, hardwareProfile.defaultPartIds, diagnostics);
568
+ const panelDeclared = [...declared, ...gridDeclared];
569
+ const declaredControlIds = new Set(panelDeclared.flatMap((candidate) => candidate.controlId === undefined ? [] : [candidate.controlId]));
570
+ const auto = autoPlacementCandidates(panel, enclosure, panelDeclared, declaredControlIds, options, hardwareProfile, placementStyle, diagnostics);
571
+ const holes = [...panelDeclared, ...auto].flatMap((candidate) => drillHoleForCandidate(candidate, hardwareProfile, diagnostics));
572
+ diagnostics.push(...validateHolePlacements(holes, enclosure, options.minPartClearanceMm));
573
+ return {
574
+ schema: "stompbox-drill-layout/v1",
575
+ units: "mm",
576
+ enclosure,
577
+ holes,
578
+ diagnostics,
579
+ };
580
+ }
581
+ export function createStompboxPreviewFromVdsp(source, options = {}) {
582
+ const document = parseCircuitDocumentFile(source, {
583
+ filename: options.filename ?? "stompbox.vdsp",
584
+ });
585
+ return createStompboxPreview(document, options);
586
+ }
587
+ export function createStompboxPreview(document, options = {}) {
588
+ const hardwareProfile = requireStompboxHardwareProfile(options);
589
+ const placementStyle = resolveStompboxPlacementStyle(options.styleProfile, hardwareProfile);
590
+ const drillLayout = createStompboxDrillLayout(document, options);
591
+ const panel = extractPanel(document);
592
+ const controlMetadata = controlMetadataById(panel);
593
+ const resolveOptions = assetResolveOptions(options);
594
+ const runtimeState = options.pedalState?.controls ?? options.state;
595
+ const enabled = options.pedalState?.enabled;
596
+ const parts = drillLayout.holes.map((hole) => previewPartForHole(hole, drillLayout.enclosure, controlMetadata.get(hole.controlId ?? ""), runtimeState, resolveOptions, options.appearance, enabled));
597
+ const decals = [
598
+ ...normalizeDecals(options.decals, drillLayout.enclosure),
599
+ ...controlLabelDecals(drillLayout, placementStyle, options.appearance),
600
+ ];
601
+ const enclosureMaterial = materialWithValues(options.appearance?.enclosure);
602
+ return {
603
+ schema: "stompbox-preview/v1",
604
+ units: "mm",
605
+ enclosure: {
606
+ variantId: drillLayout.enclosure.variantId,
607
+ label: drillLayout.enclosure.label,
608
+ dimensionsMm: drillLayout.enclosure.dimensionsMm,
609
+ topFace: drillLayout.enclosure.topFace,
610
+ assets: resolveStompboxAssetPaths(drillLayout.enclosure.assets, resolveOptions),
611
+ ...(enclosureMaterial === undefined
612
+ ? {}
613
+ : { material: enclosureMaterial }),
614
+ },
615
+ parts,
616
+ decals,
617
+ drillLayout,
618
+ diagnostics: drillLayout.diagnostics,
619
+ };
620
+ }
621
+ export function createStompboxPreviewStatePatch(preview, state, previousState) {
622
+ const parts = Object.fromEntries(preview.parts.flatMap((part) => {
623
+ const currentValue = previewStateValueForPart(part, state);
624
+ const previousValue = previousState === undefined
625
+ ? undefined
626
+ : previewStateValueForPart(part, previousState);
627
+ if (currentValue === undefined ||
628
+ sameControlValue(currentValue, previousValue)) {
629
+ return [];
630
+ }
631
+ const target = previewStatePatchTarget(part, currentValue, previousValue);
632
+ return [[target.targetId, target]];
633
+ }));
634
+ return {
635
+ schema: "stompbox-preview-state-patch/v1",
636
+ units: "mm",
637
+ pedalId: state.pedalId,
638
+ revision: state.revision,
639
+ parts,
640
+ };
641
+ }
642
+ export function applyStompboxPreviewStatePatch(preview, patch) {
643
+ const parts = preview.parts.map((part) => {
644
+ const target = patch.parts[`part-${part.id}`];
645
+ if (target === undefined) {
646
+ return part;
647
+ }
648
+ return {
649
+ ...part,
650
+ ...(target.transform === undefined
651
+ ? {}
652
+ : { transform: target.transform }),
653
+ ...(target.material === undefined ? {} : { material: target.material }),
654
+ };
655
+ });
656
+ return {
657
+ ...preview,
658
+ parts,
659
+ };
660
+ }
661
+ export function resolveStompboxAppearance(preview, appearance) {
662
+ return createStompboxAppearancePatch(preview, appearance);
663
+ }
664
+ export function createStompboxAppearancePatch(preview, appearance) {
665
+ const enclosureMaterial = mergeMaterials(preview.enclosure.material, appearance?.enclosure);
666
+ const parts = Object.fromEntries(preview.parts.flatMap((part) => {
667
+ const material = mergeMaterials(part.material, previewPartAppearanceFor(part, appearance));
668
+ if (material === undefined) {
669
+ return [];
670
+ }
671
+ const target = {
672
+ targetId: `part-${part.id}`,
673
+ partId: part.partId,
674
+ ...(part.controlId === undefined ? {} : { controlId: part.controlId }),
675
+ family: part.family,
676
+ ...material,
677
+ };
678
+ return [[target.targetId, target]];
679
+ }));
680
+ const decals = Object.fromEntries(preview.decals.flatMap((decal) => {
681
+ const labelAppearance = decalAppearanceFor(decal, appearance);
682
+ const textDecal = decal.kind === "text"
683
+ ? {
684
+ text: labelAppearance?.text ?? decal.text,
685
+ color: labelAppearance?.color ?? decal.color,
686
+ fontFamily: labelAppearance?.fontFamily ?? decal.fontFamily,
687
+ fontSizeMm: labelAppearance?.fontSizeMm ?? decal.fontSizeMm,
688
+ }
689
+ : {};
690
+ const target = {
691
+ targetId: `decal-${decal.id}`,
692
+ decalId: decal.id,
693
+ kind: decal.kind,
694
+ face: decal.face,
695
+ ...textDecal,
696
+ };
697
+ return [[target.targetId, target]];
698
+ }));
699
+ return {
700
+ schema: "stompbox-appearance-patch/v1",
701
+ units: preview.units,
702
+ ...(enclosureMaterial === undefined
703
+ ? {}
704
+ : {
705
+ enclosure: {
706
+ targetId: `enclosure-${preview.enclosure.variantId}`,
707
+ ...enclosureMaterial,
708
+ },
709
+ }),
710
+ parts,
711
+ decals,
712
+ };
713
+ }
714
+ export function createStompboxDrillTemplateFromVdsp(source, options) {
715
+ const layout = createStompboxDrillLayoutFromVdsp(source, options);
716
+ return createStompboxDrillTemplate(layout, options);
717
+ }
718
+ export function createStompboxDrillTemplate(layout, options) {
719
+ const previewCanvas = unfoldedDrillTemplateSize(layout.enclosure);
720
+ const decals = normalizeDecals(options.decals, layout.enclosure);
721
+ if (options.mode === "print") {
722
+ const page = {
723
+ paper: "A4",
724
+ orientation: "portrait",
725
+ widthMm: 210,
726
+ heightMm: 297,
727
+ marginMm: 12,
728
+ };
729
+ return {
730
+ schema: "stompbox-drill-template/v1",
731
+ mode: "print",
732
+ units: "mm",
733
+ scale: 1,
734
+ detailLevel: "fabrication-detail",
735
+ canvasMm: { widthMm: page.widthMm, heightMm: page.heightMm },
736
+ page,
737
+ enclosure: layout.enclosure,
738
+ holes: layout.holes.map((hole) => templateHole(hole, layout.enclosure, {
739
+ widthMm: page.widthMm,
740
+ heightMm: page.heightMm,
741
+ })),
742
+ scaleMarks: [
743
+ {
744
+ id: "scale-10mm",
745
+ label: "10 mm",
746
+ lengthMm: 10,
747
+ startMm: { x: 12, y: 285 },
748
+ endMm: { x: 22, y: 285 },
749
+ },
750
+ {
751
+ id: "scale-50mm",
752
+ label: "50 mm",
753
+ lengthMm: 50,
754
+ startMm: { x: 12, y: 278 },
755
+ endMm: { x: 62, y: 278 },
756
+ },
757
+ ],
758
+ decals,
759
+ ...(options.appearance === undefined
760
+ ? {}
761
+ : { appearance: options.appearance }),
762
+ holeTable: layout.holes,
763
+ diagnostics: layout.diagnostics,
764
+ };
765
+ }
766
+ return {
767
+ schema: "stompbox-drill-template/v1",
768
+ mode: "preview",
769
+ units: "mm",
770
+ scale: 1,
771
+ detailLevel: "preview",
772
+ canvasMm: previewCanvas,
773
+ page: undefined,
774
+ enclosure: layout.enclosure,
775
+ holes: layout.holes.map((hole) => templateHole(hole, layout.enclosure, previewCanvas)),
776
+ decals,
777
+ ...(options.appearance === undefined
778
+ ? {}
779
+ : { appearance: options.appearance }),
780
+ scaleMarks: [],
781
+ holeTable: [],
782
+ diagnostics: layout.diagnostics,
783
+ };
784
+ }
785
+ export function createStompboxDrillTemplateSvgFromVdsp(source, options) {
786
+ const layout = createStompboxDrillLayoutFromVdsp(source, options);
787
+ return createStompboxDrillTemplateSvg(layout, options);
788
+ }
789
+ export function createStompboxDrillTemplateSvg(layout, options) {
790
+ return drillTemplateSvg(createStompboxDrillTemplate(layout, options));
791
+ }
792
+ export function createStompboxPreviewSvgViewsFromVdsp(source, options = {}) {
793
+ const document = parseCircuitDocumentFile(source, {
794
+ filename: options.filename ?? "stompbox.vdsp",
795
+ });
796
+ return createStompboxPreviewSvgViews(document, options);
797
+ }
798
+ export function createStompboxPreviewSvgViews(document, options = {}) {
799
+ const preview = createStompboxPreview(document, options);
800
+ const grain = resolvePreviewSvgGrain(options.grain);
801
+ return {
802
+ schema: "stompbox-preview-svg-views/v1",
803
+ units: "mm",
804
+ preview,
805
+ views: {
806
+ top: previewViewSvg(preview, "top", grain),
807
+ bottom: previewViewSvg(preview, "bottom", grain),
808
+ left: previewViewSvg(preview, "left", grain),
809
+ right: previewViewSvg(preview, "right", grain),
810
+ back: previewViewSvg(preview, "back", grain),
811
+ },
812
+ diagnostics: preview.diagnostics,
813
+ };
814
+ }
815
+ export function createStompboxPreviewGlbFromVdsp(source, options = {}) {
816
+ const document = parseCircuitDocumentFile(source, {
817
+ filename: options.filename ?? "stompbox.vdsp",
818
+ });
819
+ return createStompboxPreviewGlb(document, options);
820
+ }
821
+ export function createStompboxPreviewGlb(document, options = {}) {
822
+ const preview = createStompboxPreview(document, options);
823
+ const hardwareProfile = requireStompboxHardwareProfile(options);
824
+ const assetValidation = options.basePath === undefined
825
+ ? undefined
826
+ : validateStompboxHardwareProfileAssets(hardwareProfile, {
827
+ basePath: options.basePath,
828
+ partIds: liveStatePartProfileIdsForPreview(preview),
829
+ });
830
+ const diagnostics = [
831
+ ...preview.diagnostics,
832
+ ...(assetValidation?.diagnostics ?? []),
833
+ ];
834
+ return {
835
+ schema: "stompbox-preview-glb/v1",
836
+ mimeType: "model/gltf-binary",
837
+ bytes: previewGlb(preview, options, assetValidation),
838
+ preview,
839
+ diagnostics,
840
+ };
841
+ }
842
+ function resolvePreviewSvgGrain(grain) {
843
+ if (grain !== true && (grain === undefined || grain === false)) {
844
+ return undefined;
845
+ }
846
+ const options = grain === true ? {} : grain;
847
+ return {
848
+ baseFrequency: positiveNumberOrDefault(options.baseFrequency, STOMPBOX_PREVIEW_SVG_GRAIN_BASE_FREQUENCY),
849
+ numOctaves: positiveIntegerOrDefault(options.numOctaves, STOMPBOX_PREVIEW_SVG_GRAIN_NUM_OCTAVES),
850
+ opacity: unitIntervalOrDefault(options.opacity, STOMPBOX_PREVIEW_SVG_GRAIN_OPACITY),
851
+ };
852
+ }
853
+ function positiveNumberOrDefault(value, fallback) {
854
+ return value === undefined || !Number.isFinite(value) || value <= 0
855
+ ? fallback
856
+ : value;
857
+ }
858
+ function positiveIntegerOrDefault(value, fallback) {
859
+ return Math.max(1, Math.round(positiveNumberOrDefault(value, fallback)));
860
+ }
861
+ function unitIntervalOrDefault(value, fallback) {
862
+ if (value === undefined || !Number.isFinite(value)) {
863
+ return fallback;
864
+ }
865
+ return Math.max(0, Math.min(1, value));
866
+ }
867
+ export function knobRotationDegForPosition(position) {
868
+ return 135 - clamp01(position) * 270;
869
+ }
870
+ function nonEmptyText(value) {
871
+ const trimmed = value?.trim();
872
+ return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
873
+ }
874
+ function sourcePanelControlDefaultValue(kind, component) {
875
+ if (kind === "switch") {
876
+ const rawPosition = sourcePanelControlPropertyText(component, "Position");
877
+ const parsedPosition = rawPosition === undefined ? Number.NaN : Number.parseFloat(rawPosition);
878
+ return Number.isFinite(parsedPosition) ? parsedPosition : 0;
879
+ }
880
+ const rawWipe = sourcePanelControlPropertyText(component, "Wipe");
881
+ const parsedWipe = rawWipe === undefined ? Number.NaN : Number.parseFloat(rawWipe);
882
+ return Number.isFinite(parsedWipe) ? clamp01(parsedWipe) : 0.5;
883
+ }
884
+ function sourcePanelControlPropertyText(component, key) {
885
+ const value = component?.properties[key];
886
+ if (value === undefined || value === null) {
887
+ return undefined;
888
+ }
889
+ if (typeof value === "string") {
890
+ return value;
891
+ }
892
+ if (typeof value === "number" || typeof value === "boolean") {
893
+ return String(value);
894
+ }
895
+ if (typeof value === "object") {
896
+ if ("raw" in value && value.raw !== undefined && value.raw !== null) {
897
+ return String(value.raw);
898
+ }
899
+ if ("value" in value && value.value !== undefined && value.value !== null) {
900
+ return String(value.value);
901
+ }
902
+ }
903
+ return undefined;
904
+ }
905
+ function sourcePanelControlOptions(component) {
906
+ const raw = sourcePanelControlPropertyText(component, "ControlOptions") ??
907
+ sourcePanelControlPropertyText(component, "StepLabels") ??
908
+ sourcePanelControlPropertyText(component, "Options");
909
+ const options = raw
910
+ ?.split(",")
911
+ .map((option) => option.trim())
912
+ .filter((option) => option.length > 0) ?? [];
913
+ return options.length === 0 ? undefined : options;
914
+ }
915
+ function sourceControlsFromExtractedPanel(panel) {
916
+ return [
917
+ ...panel.knobs.map((knob) => ({
918
+ id: knob.id,
919
+ label: knob.name,
920
+ kind: "knob",
921
+ value: knob.defaultPosition,
922
+ sourceComponentId: knob.id,
923
+ ...(knob.description === undefined
924
+ ? {}
925
+ : { description: knob.description }),
926
+ })),
927
+ ...panel.switches.map((switchControl) => ({
928
+ id: switchControl.id,
929
+ label: switchControl.name,
930
+ kind: "switch",
931
+ value: switchControl.defaultPosition,
932
+ sourceComponentId: switchControl.id,
933
+ ...(switchControl.description === undefined
934
+ ? {}
935
+ : { description: switchControl.description }),
936
+ })),
937
+ ];
938
+ }
939
+ function findMatchingCompiledControlIndex(sourceControl, compiledControls, diagnostics) {
940
+ if (sourceControl.sourceComponentId !== undefined) {
941
+ const byComponent = compiledControls.findIndex((control) => control.sourceComponentId === sourceControl.sourceComponentId);
942
+ if (byComponent >= 0) {
943
+ return byComponent;
944
+ }
945
+ }
946
+ const normalizedLabel = normalizeRuntimeControlName(sourceControl.label);
947
+ const labelMatches = compiledControls
948
+ .map((control, index) => ({ control, index }))
949
+ .filter(({ control }) => normalizeRuntimeControlName(control.name) === normalizedLabel);
950
+ if (labelMatches.length > 1) {
951
+ diagnostics.push({
952
+ code: "unsupported-control",
953
+ message: `Ambiguous compiled control match for source panel control "${sourceControl.label}"`,
954
+ controlId: sourceControl.id,
955
+ });
956
+ return -1;
957
+ }
958
+ return labelMatches[0]?.index ?? -1;
959
+ }
960
+ function runtimeControlDescriptor(sourceControl, compiledControl) {
961
+ const kind = compiledControl === undefined
962
+ ? (sourceControl?.kind ?? "knob")
963
+ : compiledControl.kind === "switch"
964
+ ? "switch"
965
+ : "knob";
966
+ const min = compiledControl?.min ?? (sourceControl?.kind === "switch" ? 0 : 0);
967
+ const optionCount = sourceControl?.options?.length ?? compiledControl?.options?.length ?? 0;
968
+ const max = compiledControl?.max ??
969
+ (kind === "switch" || optionCount > 0 ? Math.max(1, optionCount - 1) : 1);
970
+ const step = compiledControl?.step ?? (kind === "switch" || optionCount > 0 ? 1 : 0.01);
971
+ const rawValue = compiledControl === undefined
972
+ ? (sourceControl?.value ?? min)
973
+ : effectiveCompiledControlValue(compiledControl);
974
+ const id = sourceControl?.sourceComponentId ??
975
+ compiledControl?.sourceComponentId ??
976
+ sourceControl?.id ??
977
+ publicRuntimeControlId(compiledControl?.id ?? compiledControl?.name ?? "control");
978
+ const label = sourceControl?.label ?? compiledControl?.name ?? id;
979
+ const normalizedValue = kind === "switch"
980
+ ? {
981
+ kind: "switch",
982
+ position: switchPositionForRawValue(rawValue, { min, max, step }),
983
+ }
984
+ : {
985
+ kind: "knob",
986
+ position: normalizeRawPosition(rawValue, min, max),
987
+ };
988
+ const sourceComponentId = sourceControl?.sourceComponentId ?? compiledControl?.sourceComponentId;
989
+ const sweep = sourceControl?.sweep ?? compiledControl?.sweep;
990
+ const options = sourceControl?.options ?? compiledControl?.options;
991
+ return {
992
+ id,
993
+ label,
994
+ kind,
995
+ source: compiledControl === undefined ? "source-panel" : "compiled",
996
+ value: rawValue,
997
+ min,
998
+ max,
999
+ step,
1000
+ normalizedValue,
1001
+ ...(sourceComponentId === undefined ? {} : { sourceComponentId }),
1002
+ ...(sourceControl?.panelElementId === undefined
1003
+ ? {}
1004
+ : { panelElementId: sourceControl.panelElementId }),
1005
+ ...(compiledControl?.id === undefined
1006
+ ? {}
1007
+ : { runtimeControlId: compiledControl.id }),
1008
+ ...(compiledControl?.kind === undefined
1009
+ ? {}
1010
+ : { controlKind: compiledControl.kind }),
1011
+ ...(compiledControl?.unit === undefined
1012
+ ? {}
1013
+ : { unit: compiledControl.unit }),
1014
+ ...(sweep === undefined ? {} : { sweep }),
1015
+ ...(options === undefined ? {} : { options }),
1016
+ ...(sourceControl?.description === undefined
1017
+ ? {}
1018
+ : { description: sourceControl.description }),
1019
+ ...(compiledControl?.targets === undefined
1020
+ ? {}
1021
+ : { targetCount: compiledControl.targets.length }),
1022
+ };
1023
+ }
1024
+ function effectiveCompiledControlValue(control) {
1025
+ if (control.kind === "switch" ||
1026
+ control.options !== undefined ||
1027
+ control.defaultBehavior === "source") {
1028
+ return control.value;
1029
+ }
1030
+ return control.min + (control.max - control.min) / 2;
1031
+ }
1032
+ function runtimeRouteForDescriptor(descriptor) {
1033
+ return {
1034
+ publicControlId: descriptor.id,
1035
+ ...(descriptor.runtimeControlId === undefined
1036
+ ? {}
1037
+ : { runtimeControlId: descriptor.runtimeControlId }),
1038
+ ...(descriptor.sourceComponentId === undefined
1039
+ ? {}
1040
+ : { sourceComponentId: descriptor.sourceComponentId }),
1041
+ kind: descriptor.kind,
1042
+ source: descriptor.source,
1043
+ min: descriptor.min,
1044
+ max: descriptor.max,
1045
+ step: descriptor.step,
1046
+ };
1047
+ }
1048
+ function panelFromRuntimeControls(basePanel, controls) {
1049
+ return {
1050
+ ...(basePanel.placement === undefined
1051
+ ? {}
1052
+ : { placement: basePanel.placement }),
1053
+ knobs: controls
1054
+ .filter((control) => control.kind === "knob")
1055
+ .map((control) => ({
1056
+ id: control.id,
1057
+ name: control.label,
1058
+ taper: taperFromSweep(control.sweep),
1059
+ defaultPosition: control.normalizedValue.kind === "knob"
1060
+ ? control.normalizedValue.position
1061
+ : 0.5,
1062
+ ...(control.description === undefined
1063
+ ? {}
1064
+ : { description: control.description }),
1065
+ })),
1066
+ ...(basePanel.sliders === undefined ? {} : { sliders: basePanel.sliders }),
1067
+ switches: controls
1068
+ .filter((control) => control.kind === "switch")
1069
+ .map((control) => ({
1070
+ id: control.id,
1071
+ name: control.label,
1072
+ switchKind: "spst",
1073
+ poles: 1,
1074
+ positions: Math.max(2, Math.round((control.max - control.min) / Math.max(1, control.step)) + 1),
1075
+ defaultPosition: control.normalizedValue.kind === "switch"
1076
+ ? control.normalizedValue.position
1077
+ : 0,
1078
+ ...(control.description === undefined
1079
+ ? {}
1080
+ : { description: control.description }),
1081
+ })),
1082
+ leds: basePanel.leds,
1083
+ jacks: basePanel.jacks,
1084
+ };
1085
+ }
1086
+ function taperFromSweep(sweep) {
1087
+ const lower = sweep?.toLowerCase();
1088
+ if (lower === undefined) {
1089
+ return "unknown";
1090
+ }
1091
+ if (lower.includes("rev") && lower.includes("log")) {
1092
+ return "reverse-log";
1093
+ }
1094
+ if (lower.includes("log") || lower.includes("audio")) {
1095
+ return "log";
1096
+ }
1097
+ if (lower.includes("lin")) {
1098
+ return "linear";
1099
+ }
1100
+ return "unknown";
1101
+ }
1102
+ function normalizeRuntimeControlName(value) {
1103
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
1104
+ }
1105
+ function publicRuntimeControlId(value) {
1106
+ return value.startsWith("control-") ? value.slice("control-".length) : value;
1107
+ }
1108
+ function normalizeRawPosition(value, min, max) {
1109
+ const span = max - min;
1110
+ if (!Number.isFinite(span) || span <= 0) {
1111
+ return 0;
1112
+ }
1113
+ return clamp01((value - min) / span);
1114
+ }
1115
+ function switchPositionForRawValue(value, range) {
1116
+ const step = Math.max(1, range.step);
1117
+ const positions = Math.max(2, Math.round((range.max - range.min) / step) + 1);
1118
+ return Math.max(0, Math.min(positions - 1, Math.round((value - range.min) / step)));
1119
+ }
1120
+ function normalizedControlValue(value) {
1121
+ if (value.kind === "knob" || value.kind === "slider") {
1122
+ return { ...value, position: clamp01(value.position) };
1123
+ }
1124
+ if (value.kind === "switch") {
1125
+ return {
1126
+ kind: "switch",
1127
+ position: Math.max(0, Math.round(value.position)),
1128
+ };
1129
+ }
1130
+ return {
1131
+ kind: "led",
1132
+ on: value.on,
1133
+ ...(value.intensity === undefined
1134
+ ? {}
1135
+ : { intensity: clamp01(value.intensity) }),
1136
+ };
1137
+ }
1138
+ function sameControlValue(first, second) {
1139
+ if (first === undefined || second === undefined) {
1140
+ return first === second;
1141
+ }
1142
+ if (first.kind !== second.kind) {
1143
+ return false;
1144
+ }
1145
+ if (first.kind === "knob" && second.kind === "knob") {
1146
+ return first.position === second.position;
1147
+ }
1148
+ if (first.kind === "slider" && second.kind === "slider") {
1149
+ return first.position === second.position;
1150
+ }
1151
+ if (first.kind === "switch" && second.kind === "switch") {
1152
+ return first.position === second.position;
1153
+ }
1154
+ if (first.kind === "led" && second.kind === "led") {
1155
+ return (first.on === second.on &&
1156
+ (first.intensity ?? 0) === (second.intensity ?? 0));
1157
+ }
1158
+ return false;
1159
+ }
1160
+ function controlIdFromSwitchPartId(partId) {
1161
+ return partId?.startsWith("switch-") === true
1162
+ ? partId.slice("switch-".length)
1163
+ : undefined;
1164
+ }
1165
+ function subscribeSet(listeners, listener) {
1166
+ listeners.add(listener);
1167
+ return () => {
1168
+ listeners.delete(listener);
1169
+ };
1170
+ }
1171
+ function changedControlIdsForState(previous, current) {
1172
+ const ids = new Set([
1173
+ ...Object.keys(previous.controls),
1174
+ ...Object.keys(current.controls),
1175
+ ]);
1176
+ return [...ids].filter((id) => !sameControlValue(previous.controls[id], current.controls[id]));
1177
+ }
1178
+ function stateValueForHole(hole, state, enabled) {
1179
+ if (hole.controlId !== undefined) {
1180
+ return state?.[hole.controlId];
1181
+ }
1182
+ if (enabled === undefined) {
1183
+ return undefined;
1184
+ }
1185
+ if (hole.id === "led-status") {
1186
+ return { kind: "led", on: enabled };
1187
+ }
1188
+ if (hole.id === "switch-bypass") {
1189
+ return { kind: "switch", position: enabled ? 1 : 0 };
1190
+ }
1191
+ return undefined;
1192
+ }
1193
+ function previewStateValueForPart(part, state) {
1194
+ if (part.controlId !== undefined) {
1195
+ return state.controls[part.controlId];
1196
+ }
1197
+ if (part.id === "led-status") {
1198
+ return { kind: "led", on: state.enabled };
1199
+ }
1200
+ if (part.id === "switch-bypass") {
1201
+ return { kind: "switch", position: state.enabled ? 1 : 0 };
1202
+ }
1203
+ return undefined;
1204
+ }
1205
+ function previewStatePatchTarget(part, value, previousValue) {
1206
+ const stateTarget = previewStateTargetForPart(part, value);
1207
+ const transform = previewStateTransformForPart(part, value, previousValue, stateTarget);
1208
+ const material = previewStateMaterialForPart(part, value);
1209
+ return {
1210
+ targetId: `part-${part.id}`,
1211
+ previewPartId: part.id,
1212
+ partId: part.partId,
1213
+ family: part.family,
1214
+ ...(part.controlId === undefined ? {} : { controlId: part.controlId }),
1215
+ value,
1216
+ ...(stateTarget === undefined ? {} : { stateTarget }),
1217
+ ...(transform === undefined ? {} : { transform }),
1218
+ ...(material === undefined ? {} : { material }),
1219
+ };
1220
+ }
1221
+ function previewStateTransformForPart(part, value, previousValue, stateTarget) {
1222
+ if (part.geometry.kind === "knob" && value.kind === "knob") {
1223
+ return {
1224
+ ...part.transform,
1225
+ rotationDeg: {
1226
+ ...part.transform.rotationDeg,
1227
+ z: knobRotationDegForPosition(value.position),
1228
+ },
1229
+ };
1230
+ }
1231
+ if (part.geometry.kind === "footswitch" &&
1232
+ value.kind === "switch" &&
1233
+ stateTarget === undefined) {
1234
+ const previousOffset = previousValue?.kind === "switch" && previousValue.position > 0
1235
+ ? -part.geometry.pressedTravelMm
1236
+ : 0;
1237
+ const baseZ = part.transform.translationMm.z - previousOffset;
1238
+ const nextOffset = value.position > 0 ? -part.geometry.pressedTravelMm : 0;
1239
+ return {
1240
+ ...part.transform,
1241
+ translationMm: {
1242
+ ...part.transform.translationMm,
1243
+ z: roundMillimeters(baseZ + nextOffset),
1244
+ },
1245
+ };
1246
+ }
1247
+ return undefined;
1248
+ }
1249
+ function previewStateMaterialForPart(part, value) {
1250
+ if (part.family !== "led" || value.kind !== "led") {
1251
+ return undefined;
1252
+ }
1253
+ if (!value.on) {
1254
+ return {
1255
+ ...(part.material ?? {}),
1256
+ emissive: false,
1257
+ intensity: 0,
1258
+ };
1259
+ }
1260
+ return {
1261
+ ...(part.material ?? {}),
1262
+ emissive: true,
1263
+ intensity: value.intensity ?? 1,
1264
+ };
1265
+ }
1266
+ function previewStateTargetForPart(part, value) {
1267
+ if (part.family === "led" && value.kind === "led") {
1268
+ return resolvedStateTargetForPart(part.id, "led.lens", part.stateTargets?.led?.lens);
1269
+ }
1270
+ if (part.geometry.kind === "footswitch" && value.kind === "switch") {
1271
+ return resolvedStateTargetForPart(part.id, "footswitch.actuator", part.stateTargets?.footswitch?.actuator, {
1272
+ travelAxis: part.stateTargets?.footswitch?.travelAxis ?? "z",
1273
+ travelMm: part.stateTargets?.footswitch?.travelMm ??
1274
+ part.geometry.pressedTravelMm,
1275
+ });
1276
+ }
1277
+ return undefined;
1278
+ }
1279
+ function resolvedStateTargetForPart(previewPartId, role, target, motion) {
1280
+ if (target?.selector.nodeName === undefined) {
1281
+ return undefined;
1282
+ }
1283
+ return {
1284
+ role,
1285
+ selector: target.selector,
1286
+ nodeName: `${previewPartId}/${target.selector.nodeName}`,
1287
+ ...(target.selector.meshName === undefined
1288
+ ? {}
1289
+ : { meshName: `${previewPartId}/${target.selector.meshName}` }),
1290
+ ...(target.selector.materialName === undefined
1291
+ ? {}
1292
+ : { materialName: `${previewPartId}/${target.selector.materialName}` }),
1293
+ ...(motion === undefined ? {} : motion),
1294
+ };
1295
+ }
1296
+ function requiredStateTargetsForPartProfile(partProfile) {
1297
+ if (partProfile.family === "led" &&
1298
+ (partProfile.geometry.kind === "led" ||
1299
+ partProfile.geometry.kind === "led-bezel")) {
1300
+ return [
1301
+ {
1302
+ role: "led.lens",
1303
+ target: partProfile.stateTargets?.led?.lens,
1304
+ },
1305
+ ];
1306
+ }
1307
+ if (partProfile.family === "footswitch" &&
1308
+ partProfile.geometry.kind === "footswitch") {
1309
+ return [
1310
+ {
1311
+ role: "footswitch.actuator",
1312
+ target: partProfile.stateTargets?.footswitch?.actuator,
1313
+ motion: {
1314
+ travelAxis: partProfile.stateTargets?.footswitch?.travelAxis ?? "z",
1315
+ travelMm: partProfile.stateTargets?.footswitch?.travelMm ??
1316
+ partProfile.geometry.pressedTravelMm,
1317
+ },
1318
+ },
1319
+ ];
1320
+ }
1321
+ return [];
1322
+ }
1323
+ function defaultLiveStatePartProfileIds(hardwareProfile) {
1324
+ return uniqueStrings([
1325
+ hardwareProfile.defaultPartIds.led,
1326
+ hardwareProfile.defaultPartIds.footswitch,
1327
+ ]);
1328
+ }
1329
+ function liveStatePartProfileIdsForPreview(preview) {
1330
+ return uniqueStrings(preview.parts.flatMap((part) => part.family === "led" || part.family === "footswitch"
1331
+ ? [part.partId]
1332
+ : []));
1333
+ }
1334
+ function uniqueStrings(values) {
1335
+ return [...new Set(values)];
1336
+ }
1337
+ function glbStateTargetCandidates(json) {
1338
+ const nodes = jsonObjectArray(json, "nodes");
1339
+ const meshes = jsonObjectArray(json, "meshes");
1340
+ const materials = jsonObjectArray(json, "materials");
1341
+ return nodes.flatMap((node) => {
1342
+ const nodeName = typeof node.name === "string" ? node.name : undefined;
1343
+ if (nodeName === undefined) {
1344
+ return [];
1345
+ }
1346
+ const mesh = typeof node.mesh === "number" ? meshes[node.mesh] : undefined;
1347
+ const meshName = typeof mesh?.name === "string" ? mesh.name : undefined;
1348
+ const materialNames = mesh === undefined ? [] : materialNamesForMesh(mesh, materials);
1349
+ const materialName = materialNames[0];
1350
+ const extras = jsonObjectValue(node.extras);
1351
+ return [
1352
+ {
1353
+ nodeName,
1354
+ ...(meshName === undefined ? {} : { meshName }),
1355
+ ...(materialName === undefined ? {} : { materialName }),
1356
+ materialNames,
1357
+ ...(extras === undefined ? {} : { extras }),
1358
+ },
1359
+ ];
1360
+ });
1361
+ }
1362
+ function materialNamesForMesh(mesh, materials) {
1363
+ const names = [];
1364
+ for (const primitive of jsonObjectArray(mesh, "primitives")) {
1365
+ if (typeof primitive.material !== "number") {
1366
+ continue;
1367
+ }
1368
+ const materialName = materials[primitive.material]?.name;
1369
+ if (typeof materialName === "string") {
1370
+ names.push(materialName);
1371
+ }
1372
+ }
1373
+ return uniqueStrings(names);
1374
+ }
1375
+ function stateTargetCandidateMatches(candidate, selector) {
1376
+ return (stringSelectorMatches(candidate.nodeName, selector.nodeName, selector.nodeNameIncludes) &&
1377
+ stringSelectorMatches(candidate.meshName, selector.meshName, selector.meshNameIncludes) &&
1378
+ materialSelectorMatches(candidate.materialNames, selector.materialName, selector.materialNameIncludes) &&
1379
+ extrasSelectorMatches(candidate.extras, selector.extras));
1380
+ }
1381
+ function stringSelectorMatches(value, exact, includes) {
1382
+ if (exact !== undefined && value !== exact) {
1383
+ return false;
1384
+ }
1385
+ if (includes !== undefined && value?.includes(includes) !== true) {
1386
+ return false;
1387
+ }
1388
+ return true;
1389
+ }
1390
+ function materialSelectorMatches(values, exact, includes) {
1391
+ if (exact !== undefined && !values.includes(exact)) {
1392
+ return false;
1393
+ }
1394
+ if (includes !== undefined &&
1395
+ !values.some((value) => value.includes(includes))) {
1396
+ return false;
1397
+ }
1398
+ return true;
1399
+ }
1400
+ function extrasSelectorMatches(extras, expected) {
1401
+ if (expected === undefined) {
1402
+ return true;
1403
+ }
1404
+ if (extras === undefined) {
1405
+ return false;
1406
+ }
1407
+ return Object.entries(expected).every(([key, value]) => extras[key] === value);
1408
+ }
1409
+ function requireStompboxHardwareProfile(options) {
1410
+ if (options.hardwareProfile === undefined) {
1411
+ throw new Error("stompbox hardware profile is required; pass options.hardwareProfile from the calling application");
1412
+ }
1413
+ return options.hardwareProfile;
1414
+ }
1415
+ function resolveStompboxPlacementStyle(profile, hardwareProfile) {
1416
+ return {
1417
+ defaultPartIds: {
1418
+ ...hardwareProfile.defaultPartIds,
1419
+ ...(profile?.defaultPartIds ?? {}),
1420
+ },
1421
+ knobGrid: profile?.layout?.knobGrid ?? "large-merged-row",
1422
+ sideHardware: profile?.layout?.sideHardware ?? "side-power-five-slot",
1423
+ audioJackLabels: profile?.layout?.audioJackLabels ?? "edge-rotated",
1424
+ footswitch: profile?.layout?.footswitch ?? "lower-row",
1425
+ ...(profile?.layout?.statusLedLabel === undefined
1426
+ ? {}
1427
+ : { statusLedLabel: profile.layout.statusLedLabel }),
1428
+ };
1429
+ }
1430
+ function enclosureProfile(enclosureId, hardwareProfile) {
1431
+ const id = enclosureId ?? hardwareProfile.defaultEnclosureId;
1432
+ const profile = hardwareProfile.enclosureProfiles[id];
1433
+ if (profile === undefined) {
1434
+ throw new Error(`unsupported stompbox enclosure: ${id}`);
1435
+ }
1436
+ return profile;
1437
+ }
1438
+ function controlMetadataById(panel) {
1439
+ const metadata = new Map();
1440
+ for (const knob of panel.knobs) {
1441
+ metadata.set(knob.id, knobMetadata(knob));
1442
+ }
1443
+ for (const slider of panel.sliders ?? []) {
1444
+ metadata.set(slider.id, sliderMetadata(slider));
1445
+ }
1446
+ for (const switchControl of panel.switches) {
1447
+ metadata.set(switchControl.id, switchMetadata(switchControl));
1448
+ }
1449
+ for (const led of panel.leds) {
1450
+ metadata.set(led.id, ledMetadata(led));
1451
+ }
1452
+ for (const jack of panel.jacks) {
1453
+ metadata.set(jack.id, jackMetadata(jack));
1454
+ }
1455
+ return metadata;
1456
+ }
1457
+ function knobMetadata(knob) {
1458
+ return {
1459
+ id: knob.id,
1460
+ kind: "knob",
1461
+ label: knob.name,
1462
+ defaultPosition: knob.defaultPosition,
1463
+ };
1464
+ }
1465
+ function sliderMetadata(slider) {
1466
+ return {
1467
+ id: slider.id,
1468
+ kind: "slider",
1469
+ label: slider.name,
1470
+ defaultPosition: slider.defaultPosition,
1471
+ };
1472
+ }
1473
+ function switchMetadata(switchControl) {
1474
+ return {
1475
+ id: switchControl.id,
1476
+ kind: "switch",
1477
+ label: switchControl.name,
1478
+ defaultPosition: switchControl.defaultPosition,
1479
+ switchKind: switchControl.switchKind,
1480
+ ...(switchControl.partNumber === undefined
1481
+ ? {}
1482
+ : { partNumber: switchControl.partNumber }),
1483
+ };
1484
+ }
1485
+ function ledMetadata(led) {
1486
+ return {
1487
+ id: led.id,
1488
+ kind: "led",
1489
+ label: led.name,
1490
+ ...(led.color === undefined ? {} : { color: led.color }),
1491
+ };
1492
+ }
1493
+ function jackMetadata(jack) {
1494
+ return {
1495
+ id: jack.id,
1496
+ kind: "jack",
1497
+ label: jack.name,
1498
+ jackRole: jack.role,
1499
+ };
1500
+ }
1501
+ function declaredPhysicalPlacements(faces, controls, hardwareProfile, defaultPartIds, diagnostics) {
1502
+ const candidates = [];
1503
+ for (const face of faces) {
1504
+ for (const element of face.elements) {
1505
+ if (element.physical?.centerMm === undefined) {
1506
+ continue;
1507
+ }
1508
+ const controlId = controlIdForPanelElement(element);
1509
+ const metadata = controls.get(controlId);
1510
+ const requestedPartId = element.physical.partProfileId ??
1511
+ defaultPartIdForPanelKind(element.kind, metadata, defaultPartIds);
1512
+ const partId = knownPartIdOrDefault(requestedPartId, element.kind, metadata, hardwareProfile, defaultPartIds, diagnostics, controlId, element.id);
1513
+ if (partId === undefined) {
1514
+ diagnostics.push({
1515
+ code: "unsupported-control",
1516
+ message: `Unsupported declared panel element kind "${element.kind}"`,
1517
+ controlId,
1518
+ ...(element.id === undefined ? {} : { placementId: element.id }),
1519
+ face: face.id,
1520
+ });
1521
+ continue;
1522
+ }
1523
+ const label = element.label ?? metadata?.label;
1524
+ candidates.push({
1525
+ id: element.id ?? placementIdForKind(element.kind, controlId),
1526
+ kind: element.kind,
1527
+ face: face.id,
1528
+ centerMm: pointFromCorePoint(element.physical.centerMm),
1529
+ partId,
1530
+ componentId: element.bind.componentId,
1531
+ controlId,
1532
+ ...(label === undefined ? {} : { label }),
1533
+ ...(element.physical.drillDiameterMm === undefined
1534
+ ? {}
1535
+ : { drillDiameterMm: element.physical.drillDiameterMm }),
1536
+ ...(element.physical.locked === undefined
1537
+ ? {}
1538
+ : { locked: element.physical.locked }),
1539
+ provenance: "vdsp-declared",
1540
+ });
1541
+ }
1542
+ }
1543
+ return candidates;
1544
+ }
1545
+ function gridPhysicalPlacements(faces, controls, enclosure, hardwareProfile, defaultPartIds, diagnostics) {
1546
+ const candidates = [];
1547
+ for (const face of faces) {
1548
+ for (const element of face.elements) {
1549
+ if (element.physical?.centerMm !== undefined) {
1550
+ continue;
1551
+ }
1552
+ const controlId = controlIdForPanelElement(element);
1553
+ const metadata = controls.get(controlId);
1554
+ const requestedPartId = element.physical?.partProfileId ??
1555
+ defaultPartIdForPanelKind(element.kind, metadata, defaultPartIds);
1556
+ const partId = knownPartIdOrDefault(requestedPartId, element.kind, metadata, hardwareProfile, defaultPartIds, diagnostics, controlId, element.id);
1557
+ if (partId === undefined) {
1558
+ diagnostics.push({
1559
+ code: "unsupported-control",
1560
+ message: `Unsupported panel grid element kind "${element.kind}"`,
1561
+ controlId,
1562
+ ...(element.id === undefined ? {} : { placementId: element.id }),
1563
+ face: face.id,
1564
+ });
1565
+ continue;
1566
+ }
1567
+ const label = element.label ?? metadata?.label;
1568
+ candidates.push(autoCandidate({
1569
+ id: element.id ?? placementIdForKind(element.kind, controlId),
1570
+ kind: element.kind,
1571
+ face: face.id,
1572
+ centerMm: panelGridCenterMm(face, element, enclosure),
1573
+ partId,
1574
+ componentId: element.bind.componentId,
1575
+ controlId,
1576
+ ...(label === undefined ? {} : { label }),
1577
+ ...(element.physical?.drillDiameterMm === undefined
1578
+ ? {}
1579
+ : { drillDiameterMm: element.physical.drillDiameterMm }),
1580
+ ...(element.physical?.locked === undefined
1581
+ ? {}
1582
+ : { locked: element.physical.locked }),
1583
+ }, diagnostics));
1584
+ }
1585
+ }
1586
+ return candidates;
1587
+ }
1588
+ function autoPlacementCandidates(panel, enclosure, declared, declaredControlIds, options, hardwareProfile, placementStyle, diagnostics) {
1589
+ const candidates = [];
1590
+ const knobs = panel.knobs.filter((knob) => !declaredControlIds.has(knob.id));
1591
+ const grid = placementGrid(enclosure);
1592
+ const usesTopEdgeLed = placementStyle.knobGrid === "compact-led-row" &&
1593
+ (knobs.length === 2 || knobs.length === 3 || knobs.length === 4);
1594
+ const knobGrid = autoKnobGrid(knobs.length, grid, placementStyle, hardwareProfile);
1595
+ knobs.forEach((knob, index) => {
1596
+ const placement = knobGrid.placements[index];
1597
+ if (placement === undefined) {
1598
+ return;
1599
+ }
1600
+ candidates.push(autoCandidate({
1601
+ id: `knob-${knob.id}`,
1602
+ kind: "knob",
1603
+ centerMm: placement.centerMm,
1604
+ partId: placement.partId,
1605
+ componentId: knob.id,
1606
+ controlId: knob.id,
1607
+ label: knob.name,
1608
+ }, diagnostics));
1609
+ });
1610
+ const leds = panel.leds.filter((led) => !declaredControlIds.has(led.id));
1611
+ const ledY = usesTopEdgeLed
1612
+ ? topEdgeLedY(grid, hardwareProfile, placementStyle.defaultPartIds)
1613
+ : lowerTopLedY(knobs.length, grid);
1614
+ const ledPositions = distributedTopRowPositions(leds.length, ledY, 16);
1615
+ leds.forEach((led, index) => {
1616
+ const position = ledPositions[index];
1617
+ if (position === undefined) {
1618
+ return;
1619
+ }
1620
+ candidates.push(autoCandidate({
1621
+ id: `led-${led.id}`,
1622
+ kind: "led",
1623
+ centerMm: position,
1624
+ partId: placementStyle.defaultPartIds.led,
1625
+ componentId: led.id,
1626
+ controlId: led.id,
1627
+ label: led.name,
1628
+ }, diagnostics));
1629
+ });
1630
+ if (!hasStatusLed(panel, declared)) {
1631
+ candidates.push(autoCandidate({
1632
+ id: "led-status",
1633
+ kind: "led",
1634
+ centerMm: { x: 0, y: ledY },
1635
+ partId: placementStyle.defaultPartIds.led,
1636
+ label: "Status",
1637
+ }, diagnostics));
1638
+ }
1639
+ const switches = panel.switches.filter((switchControl) => !declaredControlIds.has(switchControl.id));
1640
+ const footswitchY = footswitchGridY(knobs.length, grid, placementStyle);
1641
+ switches.forEach((switchControl, index) => {
1642
+ if (!isSupportedFootswitch(switchControl)) {
1643
+ diagnostics.push({
1644
+ code: "unsupported-control",
1645
+ message: `Switch "${switchControl.id}" is not a supported stompbox footswitch`,
1646
+ controlId: switchControl.id,
1647
+ });
1648
+ return;
1649
+ }
1650
+ candidates.push(autoCandidate({
1651
+ id: `switch-${switchControl.id}`,
1652
+ kind: "footswitch",
1653
+ centerMm: { x: index * 18, y: footswitchY },
1654
+ partId: placementStyle.defaultPartIds.footswitch,
1655
+ componentId: switchControl.id,
1656
+ controlId: switchControl.id,
1657
+ }, diagnostics));
1658
+ });
1659
+ if (!hasBypassFootswitch(panel, declared)) {
1660
+ candidates.push(autoCandidate({
1661
+ id: "switch-bypass",
1662
+ kind: "footswitch",
1663
+ centerMm: { x: 0, y: footswitchY },
1664
+ partId: placementStyle.defaultPartIds.footswitch,
1665
+ }, diagnostics));
1666
+ }
1667
+ for (const slider of panel.sliders ?? []) {
1668
+ if (!declaredControlIds.has(slider.id)) {
1669
+ diagnostics.push({
1670
+ code: "unsupported-control",
1671
+ message: `Slider "${slider.id}" has no v1 stompbox stub part`,
1672
+ controlId: slider.id,
1673
+ });
1674
+ }
1675
+ }
1676
+ const jackCountsByFace = new Map();
1677
+ for (const jack of panel.jacks) {
1678
+ if (declaredControlIds.has(jack.id)) {
1679
+ continue;
1680
+ }
1681
+ const face = faceForJack(jack);
1682
+ if (face === undefined) {
1683
+ diagnostics.push({
1684
+ code: "unsupported-control",
1685
+ message: `Jack "${jack.id}" has unsupported role "${jack.role}"`,
1686
+ controlId: jack.id,
1687
+ });
1688
+ continue;
1689
+ }
1690
+ const faceIndex = jackCountsByFace.get(face) ?? 0;
1691
+ jackCountsByFace.set(face, faceIndex + 1);
1692
+ candidates.push(autoCandidate({
1693
+ id: `jack-${jack.id}`,
1694
+ kind: "jack",
1695
+ face,
1696
+ centerMm: centerForJackFace(face, enclosure, grid, placementStyle, faceIndex),
1697
+ partId: placementStyle.defaultPartIds.audioJack,
1698
+ componentId: jack.sourceComponentId ?? jack.id,
1699
+ controlId: jack.id,
1700
+ label: jack.name,
1701
+ }, diagnostics));
1702
+ }
1703
+ if (!hasInputJack(panel, declared)) {
1704
+ candidates.push(autoCandidate({
1705
+ id: "jack-input",
1706
+ kind: "jack",
1707
+ face: "right",
1708
+ centerMm: centerForJackFace("right", enclosure, grid, placementStyle, jackCountsByFace.get("right") ?? 0),
1709
+ partId: placementStyle.defaultPartIds.audioJack,
1710
+ label: "Input",
1711
+ }, diagnostics));
1712
+ jackCountsByFace.set("right", (jackCountsByFace.get("right") ?? 0) + 1);
1713
+ }
1714
+ if (!hasOutputJack(panel, declared)) {
1715
+ candidates.push(autoCandidate({
1716
+ id: "jack-output",
1717
+ kind: "jack",
1718
+ face: "left",
1719
+ centerMm: centerForJackFace("left", enclosure, grid, placementStyle, jackCountsByFace.get("left") ?? 0),
1720
+ partId: placementStyle.defaultPartIds.audioJack,
1721
+ label: "Output",
1722
+ }, diagnostics));
1723
+ jackCountsByFace.set("left", (jackCountsByFace.get("left") ?? 0) + 1);
1724
+ }
1725
+ if (options.includePowerJack !== false &&
1726
+ !hasPowerJack(declared, candidates, placementStyle.defaultPartIds)) {
1727
+ const powerFace = powerJackFace(placementStyle);
1728
+ candidates.push(autoCandidate({
1729
+ id: "power-9v",
1730
+ kind: "jack",
1731
+ face: powerFace,
1732
+ centerMm: centerForPowerJackFace(powerFace, enclosure, grid, placementStyle, hardwareProfile),
1733
+ partId: placementStyle.defaultPartIds.dcJack,
1734
+ label: "9V DC",
1735
+ }, diagnostics));
1736
+ }
1737
+ return candidates;
1738
+ }
1739
+ function autoCandidate(candidate, diagnostics) {
1740
+ diagnostics.push({
1741
+ code: "placement-auto-generated",
1742
+ message: `Auto-generated stompbox placement for "${candidate.id}"`,
1743
+ ...(candidate.controlId === undefined
1744
+ ? {}
1745
+ : { controlId: candidate.controlId }),
1746
+ placementId: candidate.id,
1747
+ face: candidate.face ?? "top",
1748
+ });
1749
+ return {
1750
+ ...candidate,
1751
+ face: candidate.face ?? "top",
1752
+ provenance: "auto-generated",
1753
+ };
1754
+ }
1755
+ function drillHoleForCandidate(candidate, hardwareProfile, diagnostics) {
1756
+ const part = hardwareProfile.partProfiles[candidate.partId];
1757
+ if (part === undefined) {
1758
+ diagnostics.push({
1759
+ code: "unknown-part-profile",
1760
+ message: `Unknown stompbox part profile "${candidate.partId}"`,
1761
+ ...(candidate.controlId === undefined
1762
+ ? {}
1763
+ : { controlId: candidate.controlId }),
1764
+ placementId: candidate.id,
1765
+ face: candidate.face,
1766
+ });
1767
+ return [];
1768
+ }
1769
+ return [
1770
+ {
1771
+ id: candidate.id,
1772
+ face: candidate.face,
1773
+ centerMm: candidate.centerMm,
1774
+ drillDiameterMm: candidate.drillDiameterMm ?? part.panelHoleDrillMm,
1775
+ ...(part.drillHoleProfileId === undefined
1776
+ ? {}
1777
+ : { drillHoleProfileId: part.drillHoleProfileId }),
1778
+ partId: part.id,
1779
+ partLabel: part.label,
1780
+ partFamily: part.family,
1781
+ partGeometry: part.geometry,
1782
+ ...(part.assetScale === undefined ? {} : { assetScale: part.assetScale }),
1783
+ ...(candidate.controlId === undefined
1784
+ ? {}
1785
+ : { controlId: candidate.controlId }),
1786
+ ...(candidate.componentId === undefined
1787
+ ? {}
1788
+ : { componentId: candidate.componentId }),
1789
+ ...(candidate.label === undefined ? {} : { label: candidate.label }),
1790
+ provenance: candidate.provenance,
1791
+ ...(candidate.locked === undefined ? {} : { locked: candidate.locked }),
1792
+ assets: part.assets,
1793
+ ...(part.stateTargets === undefined
1794
+ ? {}
1795
+ : { stateTargets: part.stateTargets }),
1796
+ },
1797
+ ];
1798
+ }
1799
+ function validateHolePlacements(holes, enclosure, minPartClearanceMm = 0) {
1800
+ const diagnostics = [];
1801
+ const requiredClearanceMm = Math.max(0, minPartClearanceMm);
1802
+ for (const hole of holes) {
1803
+ if (isOutOfBounds(hole, enclosure)) {
1804
+ diagnostics.push({
1805
+ code: "placement-out-of-bounds",
1806
+ message: `Hole "${hole.id}" is outside the ${hole.face} face bounds`,
1807
+ ...(hole.controlId === undefined ? {} : { controlId: hole.controlId }),
1808
+ placementId: hole.id,
1809
+ face: hole.face,
1810
+ });
1811
+ }
1812
+ }
1813
+ for (let i = 0; i < holes.length; i += 1) {
1814
+ const first = holes[i];
1815
+ if (first === undefined) {
1816
+ continue;
1817
+ }
1818
+ for (let j = i + 1; j < holes.length; j += 1) {
1819
+ const second = holes[j];
1820
+ if (second === undefined || first.face !== second.face) {
1821
+ continue;
1822
+ }
1823
+ const distance = Math.hypot(first.centerMm.x - second.centerMm.x, first.centerMm.y - second.centerMm.y);
1824
+ const requiredDistance = placementCollisionRadiusMm(first) + placementCollisionRadiusMm(second);
1825
+ const clearanceMm = distance - requiredDistance;
1826
+ if (clearanceMm < 0) {
1827
+ diagnostics.push({
1828
+ code: "placement-collision",
1829
+ message: `Placements "${first.id}" and "${second.id}" overlap on ${first.face}`,
1830
+ placementId: first.id,
1831
+ face: first.face,
1832
+ });
1833
+ continue;
1834
+ }
1835
+ if (clearanceMm < requiredClearanceMm) {
1836
+ diagnostics.push({
1837
+ code: "placement-clearance",
1838
+ message: `Placements "${first.id}" and "${second.id}" have ${formatMm(clearanceMm)} mm clearance on ${first.face}, below required ${formatMm(requiredClearanceMm)} mm`,
1839
+ placementId: first.id,
1840
+ face: first.face,
1841
+ });
1842
+ }
1843
+ }
1844
+ }
1845
+ return diagnostics;
1846
+ }
1847
+ function formatMm(value) {
1848
+ return Number.isInteger(value)
1849
+ ? String(value)
1850
+ : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
1851
+ }
1852
+ function placementCollisionRadiusMm(hole) {
1853
+ if ((hole.face === "left" || hole.face === "right") &&
1854
+ hole.partFamily === "audio-jack") {
1855
+ return hole.drillDiameterMm / 2;
1856
+ }
1857
+ return ((partGeometryVisibleDiameterMm(hole.partGeometry) ?? hole.drillDiameterMm) /
1858
+ 2);
1859
+ }
1860
+ function partGeometryVisibleDiameterMm(geometry) {
1861
+ if (geometry.kind === "knob") {
1862
+ return geometry.diameterMm;
1863
+ }
1864
+ if (geometry.kind === "footswitch") {
1865
+ return geometry.nutOuterDiameterMm;
1866
+ }
1867
+ if (geometry.kind === "led") {
1868
+ return geometry.flangeDiameterMm;
1869
+ }
1870
+ if (geometry.kind === "led-bezel" || geometry.kind === "ring") {
1871
+ return geometry.outerDiameterMm;
1872
+ }
1873
+ return undefined;
1874
+ }
1875
+ function partProfileVisibleDiameterMm(hardwareProfile, partId) {
1876
+ const geometry = hardwareProfile.partProfiles[partId]?.geometry;
1877
+ return geometry === undefined
1878
+ ? undefined
1879
+ : partGeometryVisibleDiameterMm(geometry);
1880
+ }
1881
+ function defaultPartVisibleDiameterMm(hardwareProfile, defaultPartIds, key, fallbackMm) {
1882
+ return (partProfileVisibleDiameterMm(hardwareProfile, defaultPartIds[key]) ??
1883
+ fallbackMm);
1884
+ }
1885
+ function isOutOfBounds(hole, enclosure) {
1886
+ const radius = placementCollisionRadiusMm(hole);
1887
+ if (hole.face === "top") {
1888
+ return (Math.abs(hole.centerMm.x) + radius > enclosure.dimensionsMm.widthMm / 2 ||
1889
+ Math.abs(hole.centerMm.y) + radius > enclosure.dimensionsMm.lengthMm / 2);
1890
+ }
1891
+ if (hole.face === "left" || hole.face === "right") {
1892
+ return (Math.abs(hole.centerMm.y) + radius > enclosure.dimensionsMm.lengthMm / 2);
1893
+ }
1894
+ if (hole.face === "back") {
1895
+ return (Math.abs(hole.centerMm.x) + radius > enclosure.dimensionsMm.widthMm / 2 ||
1896
+ Math.abs(hole.centerMm.y) + radius > enclosure.dimensionsMm.depthMm / 2);
1897
+ }
1898
+ return false;
1899
+ }
1900
+ function previewPartForHole(hole, enclosure, metadata, state, assetOptions, appearance, enabled) {
1901
+ const rotation = baseRotationForFace(hole.face);
1902
+ const stateValue = stateValueForHole(hole, state, enabled);
1903
+ const knobPosition = stateValue?.kind === "knob"
1904
+ ? stateValue.position
1905
+ : metadata?.defaultPosition;
1906
+ const zOffset = pressedOffsetMm(hole.partGeometry, stateValue);
1907
+ const material = materialForPart(hole, metadata, stateValue, appearance);
1908
+ const transform = {
1909
+ translationMm: {
1910
+ ...translationForFace(hole.face, hole.centerMm, enclosure),
1911
+ z: translationForFace(hole.face, hole.centerMm, enclosure).z + zOffset,
1912
+ },
1913
+ rotationDeg: {
1914
+ ...rotation,
1915
+ z: hole.partGeometry.kind === "knob"
1916
+ ? knobRotationDegForPosition(knobPosition ?? 0.5)
1917
+ : rotation.z,
1918
+ },
1919
+ };
1920
+ return {
1921
+ id: hole.id,
1922
+ partId: hole.partId,
1923
+ family: hole.partFamily,
1924
+ geometry: hole.partGeometry,
1925
+ ...(hole.assetScale === undefined ? {} : { assetScale: hole.assetScale }),
1926
+ ...(hole.controlId === undefined ? {} : { controlId: hole.controlId }),
1927
+ face: hole.face,
1928
+ provenance: hole.provenance,
1929
+ assets: resolveStompboxAssetPaths(hole.assets, assetOptions),
1930
+ ...(hole.stateTargets === undefined
1931
+ ? {}
1932
+ : { stateTargets: hole.stateTargets }),
1933
+ transform,
1934
+ ...(material === undefined ? {} : { material }),
1935
+ };
1936
+ }
1937
+ function normalizeDecals(decals, enclosure) {
1938
+ return decals?.map((decal) => normalizeDecal(decal, enclosure)) ?? [];
1939
+ }
1940
+ function controlLabelDecals(layout, placementStyle, appearance) {
1941
+ return layout.holes.flatMap((hole) => {
1942
+ const label = controlLabelDecal(hole, layout.enclosure, placementStyle, appearance);
1943
+ return label === undefined ? [] : [label];
1944
+ });
1945
+ }
1946
+ function controlLabelDecal(hole, enclosure, placementStyle, appearance) {
1947
+ if (hole.partFamily === "footswitch") {
1948
+ return undefined;
1949
+ }
1950
+ const labelId = `label-${decalIdSegment(hole.id)}`;
1951
+ const labelAppearance = labelAppearanceFor(labelId, hole.controlId, appearance);
1952
+ const text = labelAppearance?.text ?? controlLabelText(hole, placementStyle);
1953
+ if (text.length === 0) {
1954
+ return undefined;
1955
+ }
1956
+ const fontSizeMm = labelAppearance?.fontSizeMm ?? controlLabelFontSizeMm(hole.partFamily);
1957
+ const sizeMm = controlLabelSizeMm(text, fontSizeMm);
1958
+ const placement = controlLabelPlacement(hole, enclosure, placementStyle, sizeMm);
1959
+ return {
1960
+ id: labelId,
1961
+ kind: "text",
1962
+ text,
1963
+ face: placement.face,
1964
+ centerMm: placement.centerMm,
1965
+ sizeMm,
1966
+ rotationDeg: placement.rotationDeg,
1967
+ color: labelAppearance?.color ?? "#111827",
1968
+ fontFamily: labelAppearance?.fontFamily ?? "Arial,sans-serif",
1969
+ fontSizeMm,
1970
+ };
1971
+ }
1972
+ function controlLabelPlacement(hole, enclosure, placementStyle, sizeMm) {
1973
+ if (hole.partFamily === "audio-jack" &&
1974
+ (hole.face === "left" || hole.face === "right")) {
1975
+ const edgeSign = hole.face === "right" ? 1 : -1;
1976
+ const insetMm = placementStyle.audioJackLabels === "inline"
1977
+ ? 10
1978
+ : STOMPBOX_EDGE_ROTATED_SIDE_LABEL_GAP_MM + sizeMm.heightMm / 2;
1979
+ const labelBoundsMm = placementStyle.audioJackLabels === "inline"
1980
+ ? sizeMm
1981
+ : { widthMm: sizeMm.heightMm, heightMm: sizeMm.widthMm };
1982
+ return {
1983
+ face: "top",
1984
+ centerMm: clampTopLabelCenter({
1985
+ x: edgeSign * (enclosure.dimensionsMm.widthMm / 2 - insetMm),
1986
+ y: hole.centerMm.y,
1987
+ }, enclosure, labelBoundsMm),
1988
+ rotationDeg: placementStyle.audioJackLabels === "inline"
1989
+ ? 0
1990
+ : hole.face === "right"
1991
+ ? 90
1992
+ : -90,
1993
+ };
1994
+ }
1995
+ const visibleRadiusMm = (partGeometryVisibleDiameterMm(hole.partGeometry) ?? hole.drillDiameterMm) /
1996
+ 2;
1997
+ const gapMm = hole.partFamily === "led" ? 3 : 4;
1998
+ const labelY = hole.partFamily === "led"
1999
+ ? hole.centerMm.y + visibleRadiusMm + gapMm
2000
+ : hole.centerMm.y - visibleRadiusMm - gapMm;
2001
+ return {
2002
+ face: hole.face,
2003
+ centerMm: hole.face === "top"
2004
+ ? clampTopLabelCenter({ x: hole.centerMm.x, y: labelY }, enclosure, sizeMm)
2005
+ : { x: hole.centerMm.x, y: labelY },
2006
+ rotationDeg: 0,
2007
+ };
2008
+ }
2009
+ function controlLabelFontSizeMm(family) {
2010
+ if (family === "knob") {
2011
+ return 3.2;
2012
+ }
2013
+ if (family === "audio-jack") {
2014
+ return 3;
2015
+ }
2016
+ return 2.6;
2017
+ }
2018
+ function controlLabelSizeMm(text, fontSizeMm) {
2019
+ return {
2020
+ widthMm: roundMillimeters(Math.max(8, text.length * fontSizeMm * 0.72)),
2021
+ heightMm: roundMillimeters(fontSizeMm + 1.6),
2022
+ };
2023
+ }
2024
+ function controlLabelText(hole, placementStyle) {
2025
+ const text = formatControlLabel(hole.label ?? hole.controlId ?? hole.id);
2026
+ if (hole.partFamily === "led" &&
2027
+ placementStyle.statusLedLabel !== undefined &&
2028
+ text === "STATUS") {
2029
+ return placementStyle.statusLedLabel;
2030
+ }
2031
+ if (hole.partFamily !== "audio-jack") {
2032
+ return text;
2033
+ }
2034
+ if (text === "IN") {
2035
+ return "INPUT";
2036
+ }
2037
+ if (text === "OUT") {
2038
+ return "OUTPUT";
2039
+ }
2040
+ if (/\bINPUT\b/.test(text) || /\bOUTPUT\b/.test(text)) {
2041
+ return text;
2042
+ }
2043
+ if (hole.face === "right") {
2044
+ return "INPUT";
2045
+ }
2046
+ if (hole.face === "left") {
2047
+ return "OUTPUT";
2048
+ }
2049
+ return text;
2050
+ }
2051
+ function clampTopLabelCenter(centerMm, enclosure, sizeMm) {
2052
+ const marginMm = 1;
2053
+ const halfWidth = sizeMm.widthMm / 2;
2054
+ const halfHeight = sizeMm.heightMm / 2;
2055
+ return {
2056
+ x: roundMillimeters(clamp(centerMm.x, -enclosure.dimensionsMm.widthMm / 2 + marginMm + halfWidth, enclosure.dimensionsMm.widthMm / 2 - marginMm - halfWidth)),
2057
+ y: roundMillimeters(clamp(centerMm.y, -enclosure.dimensionsMm.lengthMm / 2 + marginMm + halfHeight, enclosure.dimensionsMm.lengthMm / 2 - marginMm - halfHeight)),
2058
+ };
2059
+ }
2060
+ function formatControlLabel(value) {
2061
+ return value.trim().replace(/[-_]+/g, " ").replace(/\s+/g, " ").toUpperCase();
2062
+ }
2063
+ function decalIdSegment(value) {
2064
+ return value.trim().replace(/[^A-Za-z0-9_.:-]+/g, "-") || "control";
2065
+ }
2066
+ function normalizeDecal(decal, enclosure) {
2067
+ const face = decal.face ?? "top";
2068
+ const sizeMm = decal.sizeMm ?? defaultDecalSize(decal.kind);
2069
+ const faceSize = decalFaceSize(face, enclosure);
2070
+ const placement = decal.placement === undefined
2071
+ ? undefined
2072
+ : normalizeDecalPlacement(decal.placement, faceSize);
2073
+ const common = {
2074
+ id: decal.id,
2075
+ face,
2076
+ centerMm: decalCenterMm(decal.centerMm, placement, face, enclosure),
2077
+ ...(placement === undefined ? {} : { placement }),
2078
+ sizeMm,
2079
+ rotationDeg: decal.rotationDeg ?? 0,
2080
+ };
2081
+ if (decal.kind === "text") {
2082
+ return {
2083
+ ...common,
2084
+ kind: "text",
2085
+ text: decal.text,
2086
+ color: decal.color ?? "#111827",
2087
+ fontFamily: decal.fontFamily ?? "Arial,sans-serif",
2088
+ fontSizeMm: roundMillimeters(decal.fontSizeMm ?? sizeMm.heightMm * 0.65),
2089
+ };
2090
+ }
2091
+ if (decal.kind === "image") {
2092
+ return {
2093
+ ...common,
2094
+ kind: "image",
2095
+ href: decal.href,
2096
+ ...(decal.mimeType === undefined ? {} : { mimeType: decal.mimeType }),
2097
+ ...(decal.color === undefined ? {} : { color: decal.color }),
2098
+ };
2099
+ }
2100
+ return {
2101
+ ...common,
2102
+ kind: "svg",
2103
+ svg: decal.svg,
2104
+ ...(decal.color === undefined ? {} : { color: decal.color }),
2105
+ };
2106
+ }
2107
+ function defaultDecalSize(kind) {
2108
+ if (kind === "text") {
2109
+ return { widthMm: 36, heightMm: 8 };
2110
+ }
2111
+ return { widthMm: 24, heightMm: 16 };
2112
+ }
2113
+ function normalizeDecalPlacement(placement, faceSize) {
2114
+ if (placement.kind === "grid") {
2115
+ const columns = gridAxisCellCount(placement.columns, faceSize.widthMm);
2116
+ const rows = gridAxisCellCount(placement.rows, faceSize.heightMm);
2117
+ return {
2118
+ kind: "grid",
2119
+ columns,
2120
+ rows,
2121
+ column: clampGridInteger(placement.column, columns),
2122
+ row: clampGridInteger(placement.row, rows),
2123
+ };
2124
+ }
2125
+ return placement;
2126
+ }
2127
+ function positiveGridInteger(value) {
2128
+ return Math.max(1, Math.trunc(Number.isFinite(value) ? value : 1));
2129
+ }
2130
+ function clampGridInteger(value, max) {
2131
+ return Math.min(Math.max(positiveGridInteger(value), 1), Math.max(1, max));
2132
+ }
2133
+ function decalCenterMm(centerMm, placement, face, enclosure) {
2134
+ if (placement?.kind === "grid") {
2135
+ return decalGridCenterMm(placement, decalFaceSize(face, enclosure));
2136
+ }
2137
+ return centerMm ?? { x: 0, y: 0 };
2138
+ }
2139
+ function decalGridCenterMm(placement, faceSize) {
2140
+ const columns = gridAxisCellCount(placement.columns, faceSize.widthMm);
2141
+ const rows = gridAxisCellCount(placement.rows, faceSize.heightMm);
2142
+ const column = clampGridInteger(placement.column, columns);
2143
+ const row = clampGridInteger(placement.row, rows);
2144
+ return {
2145
+ x: roundMillimeters(-faceSize.widthMm / 2 + faceSize.widthMm * ((column - 0.5) / columns)),
2146
+ y: roundMillimeters(faceSize.heightMm / 2 - faceSize.heightMm * ((row - 0.5) / rows)),
2147
+ };
2148
+ }
2149
+ function decalFaceSize(face, enclosure) {
2150
+ const { widthMm, lengthMm, depthMm } = enclosure.dimensionsMm;
2151
+ if (face === "left" || face === "right") {
2152
+ return { widthMm: depthMm, heightMm: lengthMm };
2153
+ }
2154
+ if (face === "back" || face === "bottom") {
2155
+ return { widthMm, heightMm: depthMm };
2156
+ }
2157
+ return { widthMm, heightMm: lengthMm };
2158
+ }
2159
+ function panelGridCenterMm(face, element, enclosure) {
2160
+ const rect = panelGridRectMm(face, enclosure);
2161
+ const columns = gridAxisCellCount(face.layout.columns, rect.widthMm);
2162
+ const rows = gridAxisCellCount(face.layout.rows, rect.heightMm);
2163
+ const column = panelGridAxisCenterIndex(element.grid.column, element.grid.columnSpan, columns, face.layout.indexing, face.layout.columnOrder === "right-to-left");
2164
+ const row = panelGridAxisCenterIndex(element.grid.row, element.grid.rowSpan, rows, face.layout.indexing, face.layout.rowOrder === "bottom-to-top");
2165
+ const local = {
2166
+ x: roundMillimeters(rect.x + rect.widthMm * ((column - 0.5) / columns)),
2167
+ y: roundMillimeters(rect.y + rect.heightMm * (1 - (row - 0.5) / rows)),
2168
+ };
2169
+ if (face.id === "right") {
2170
+ return { x: enclosure.dimensionsMm.widthMm / 2, y: local.y };
2171
+ }
2172
+ if (face.id === "left") {
2173
+ return { x: -enclosure.dimensionsMm.widthMm / 2, y: local.y };
2174
+ }
2175
+ return local;
2176
+ }
2177
+ function panelGridRectMm(face, enclosure) {
2178
+ const rect = face.geometry?.usableRectMm;
2179
+ if (rect !== undefined &&
2180
+ (face.geometry?.units === undefined || face.geometry.units === "mm") &&
2181
+ Number.isFinite(rect.x) &&
2182
+ Number.isFinite(rect.y) &&
2183
+ Number.isFinite(rect.width) &&
2184
+ Number.isFinite(rect.height) &&
2185
+ rect.width > 0 &&
2186
+ rect.height > 0) {
2187
+ return {
2188
+ x: rect.x,
2189
+ y: rect.y,
2190
+ widthMm: rect.width,
2191
+ heightMm: rect.height,
2192
+ };
2193
+ }
2194
+ const size = decalFaceSize(face.id, enclosure);
2195
+ return {
2196
+ x: -size.widthMm / 2,
2197
+ y: -size.heightMm / 2,
2198
+ widthMm: size.widthMm,
2199
+ heightMm: size.heightMm,
2200
+ };
2201
+ }
2202
+ function panelGridAxisCenterIndex(value, span, count, indexing, reverse) {
2203
+ const start = clampGridInteger(indexing === "zero-based" ? value + 1 : value, count);
2204
+ const end = clampGridInteger(start + positiveGridInteger(span ?? 1) - 1, count);
2205
+ const center = (start + end) / 2;
2206
+ return reverse ? count - center + 1 : center;
2207
+ }
2208
+ function gridAxisCellCount(requested, sizeMm) {
2209
+ const maximum = Math.max(1, Math.floor(Math.max(0, sizeMm) / STOMPBOX_GRID_MIN_CELL_MM));
2210
+ return Math.min(positiveGridInteger(requested), maximum);
2211
+ }
2212
+ function materialForPart(hole, metadata, stateValue, appearance) {
2213
+ const appearanceMaterial = partAppearanceFor(hole, appearance);
2214
+ if (hole.partFamily !== "led") {
2215
+ return appearanceMaterial;
2216
+ }
2217
+ const color = appearanceMaterial?.color ?? metadata?.color ?? "red";
2218
+ if (stateValue?.kind === "led" && stateValue.on) {
2219
+ return materialWithValues({
2220
+ ...appearanceMaterial,
2221
+ color,
2222
+ emissive: true,
2223
+ intensity: stateValue.intensity ?? 1,
2224
+ });
2225
+ }
2226
+ return materialWithValues({
2227
+ ...appearanceMaterial,
2228
+ color,
2229
+ emissive: false,
2230
+ intensity: 0,
2231
+ });
2232
+ }
2233
+ function partAppearanceFor(hole, appearance) {
2234
+ if (appearance === undefined || hole.partFamily === "knob") {
2235
+ return undefined;
2236
+ }
2237
+ const key = partAppearanceKey(hole.partFamily);
2238
+ if (key === undefined) {
2239
+ return undefined;
2240
+ }
2241
+ const controlAppearance = hole.controlId === undefined
2242
+ ? undefined
2243
+ : appearance.controls?.[hole.controlId]?.[key];
2244
+ return mergeMaterials(appearance.defaults?.[key], controlAppearance, appearance.parts?.[hole.id], appearance.parts?.[`part-${hole.id}`]);
2245
+ }
2246
+ function previewPartAppearanceFor(part, appearance) {
2247
+ if (appearance === undefined || part.family === "knob") {
2248
+ return undefined;
2249
+ }
2250
+ const key = partAppearanceKey(part.family);
2251
+ if (key === undefined) {
2252
+ return undefined;
2253
+ }
2254
+ const controlAppearance = part.controlId === undefined
2255
+ ? undefined
2256
+ : appearance.controls?.[part.controlId]?.[key];
2257
+ return mergeMaterials(appearance.defaults?.[key], controlAppearance, appearance.parts?.[part.id], appearance.parts?.[`part-${part.id}`]);
2258
+ }
2259
+ function partAppearanceKey(family) {
2260
+ if (family === "knob") {
2261
+ return undefined;
2262
+ }
2263
+ if (family === "audio-jack") {
2264
+ return "audioJack";
2265
+ }
2266
+ if (family === "dc-jack") {
2267
+ return "dcJack";
2268
+ }
2269
+ return family;
2270
+ }
2271
+ function labelAppearanceFor(labelId, controlId, appearance) {
2272
+ if (appearance === undefined) {
2273
+ return undefined;
2274
+ }
2275
+ const controlAppearance = controlId === undefined
2276
+ ? undefined
2277
+ : appearance.controls?.[controlId]?.label;
2278
+ return mergeLabelAppearances(appearance.defaults?.label, controlAppearance, appearance.labels?.[labelId], appearance.labels?.[`decal-${labelId}`]);
2279
+ }
2280
+ function decalAppearanceFor(decal, appearance) {
2281
+ if (appearance === undefined || decal.kind !== "text") {
2282
+ return undefined;
2283
+ }
2284
+ return mergeLabelAppearances(appearance.defaults?.label, appearance.labels?.[decal.id], appearance.labels?.[`decal-${decal.id}`]);
2285
+ }
2286
+ function mergeMaterials(...materials) {
2287
+ const merged = {};
2288
+ for (const material of materials) {
2289
+ if (material === undefined) {
2290
+ continue;
2291
+ }
2292
+ for (const [key, value] of Object.entries(material)) {
2293
+ if (value !== undefined) {
2294
+ merged[key] = value;
2295
+ }
2296
+ }
2297
+ }
2298
+ return Object.keys(merged).length === 0 ? undefined : merged;
2299
+ }
2300
+ function materialWithValues(material) {
2301
+ return mergeMaterials(material);
2302
+ }
2303
+ function mergeLabelAppearances(...appearances) {
2304
+ const merged = {};
2305
+ for (const appearance of appearances) {
2306
+ if (appearance === undefined) {
2307
+ continue;
2308
+ }
2309
+ for (const [key, value] of Object.entries(appearance)) {
2310
+ if (value !== undefined) {
2311
+ merged[key] = value;
2312
+ }
2313
+ }
2314
+ }
2315
+ return Object.keys(merged).length === 0 ? undefined : merged;
2316
+ }
2317
+ function pressedOffsetMm(geometry, stateValue) {
2318
+ if (geometry.kind !== "footswitch" ||
2319
+ stateValue?.kind !== "switch" ||
2320
+ stateValue.position <= 0) {
2321
+ return 0;
2322
+ }
2323
+ return -geometry.pressedTravelMm;
2324
+ }
2325
+ function translationForFace(face, centerMm, enclosure) {
2326
+ if (face === "top") {
2327
+ return {
2328
+ x: centerMm.x,
2329
+ y: centerMm.y,
2330
+ z: enclosure.dimensionsMm.depthMm / 2,
2331
+ };
2332
+ }
2333
+ if (face === "back") {
2334
+ return {
2335
+ x: centerMm.x,
2336
+ y: enclosure.dimensionsMm.lengthMm / 2,
2337
+ z: centerMm.y,
2338
+ };
2339
+ }
2340
+ return { x: centerMm.x, y: centerMm.y, z: 0 };
2341
+ }
2342
+ function baseRotationForFace(face) {
2343
+ if (face === "right") {
2344
+ return { x: 0, y: 90, z: 0 };
2345
+ }
2346
+ if (face === "left") {
2347
+ return { x: 0, y: -90, z: 0 };
2348
+ }
2349
+ if (face === "back") {
2350
+ return { x: -90, y: 0, z: 0 };
2351
+ }
2352
+ if (face === "bottom") {
2353
+ return { x: 90, y: 0, z: 0 };
2354
+ }
2355
+ return { x: 0, y: 0, z: 0 };
2356
+ }
2357
+ function drillTemplateSvg(template) {
2358
+ const titleId = `stompbox-drill-${template.mode}-title`;
2359
+ const descId = `stompbox-drill-${template.mode}-desc`;
2360
+ const title = `Stompbox drill template ${template.mode}`;
2361
+ const viewBox = `0 0 ${svgNumber(template.canvasMm.widthMm)} ${svgNumber(template.canvasMm.heightMm)}`;
2362
+ const attrs = svgAttributes([
2363
+ ["xmlns", "http://www.w3.org/2000/svg"],
2364
+ ["role", "img"],
2365
+ ["aria-labelledby", `${titleId} ${descId}`],
2366
+ ["width", `${svgNumber(template.canvasMm.widthMm)}mm`],
2367
+ ["height", `${svgNumber(template.canvasMm.heightMm)}mm`],
2368
+ ["viewBox", viewBox],
2369
+ ["data-template-mode", template.mode],
2370
+ ["data-units", template.units],
2371
+ ["data-scale", template.scale],
2372
+ ]);
2373
+ const description = template.mode === "print"
2374
+ ? "A4 1:1 stompbox drill template with scale marks."
2375
+ : "Lightweight stompbox drill template for UI preview.";
2376
+ return [
2377
+ `<svg ${attrs}>`,
2378
+ `<title id="${escapeAttribute(titleId)}">${escapeText(title)}</title>`,
2379
+ `<desc id="${escapeAttribute(descId)}">${escapeText(description)}</desc>`,
2380
+ drillTemplateStyleSvg(template),
2381
+ drillTemplateHeaderSvg(template),
2382
+ drillTemplateEnclosureSvg(template),
2383
+ drillTemplateDecalsSvg(template),
2384
+ drillTemplateScaleMarksSvg(template),
2385
+ drillTemplateHoleTableSvg(template),
2386
+ "</svg>",
2387
+ ].join("");
2388
+ }
2389
+ function drillTemplateStyleSvg(template) {
2390
+ const mode = template.mode;
2391
+ const stroke = template.appearance?.enclosure?.strokeColor ??
2392
+ (mode === "print" ? "#111827" : "#334155");
2393
+ const enclosureFill = template.appearance?.enclosure?.color ?? "#f8fafc";
2394
+ const sideFill = template.appearance?.template?.offColor ?? "#e2e8f0";
2395
+ const foldColor = template.appearance?.template?.foldColor ?? stroke;
2396
+ const guideColor = template.appearance?.template?.guideColor ?? "#64748b";
2397
+ const holeStroke = template.appearance?.template?.holeStrokeColor ?? stroke;
2398
+ const holeFill = template.appearance?.template?.holeFillColor ?? "none";
2399
+ const centerDot = template.appearance?.template?.centerDotColor ?? stroke;
2400
+ const labelColor = template.appearance?.defaults?.label?.color ?? "#111827";
2401
+ return [
2402
+ "<defs>",
2403
+ "<style>",
2404
+ `.enclosure{fill:${enclosureFill};stroke:${stroke};stroke-width:.35;}`,
2405
+ `.side-panel{fill:${sideFill};fill-opacity:.55;stroke:${stroke};stroke-width:.3;}`,
2406
+ `.fold-fill{fill:#0f172a;fill-opacity:${mode === "print" ? ".04" : ".08"};stroke:none;}`,
2407
+ `.fold-line{stroke:${foldColor};stroke-width:.18;stroke-dasharray:1.5 1.5;}`,
2408
+ `.guide-line{stroke:${guideColor};stroke-width:.14;stroke-dasharray:1.2 1.2;opacity:.55;}`,
2409
+ `.hole{fill:${holeFill};stroke:${holeStroke};stroke-width:.35;}`,
2410
+ `.drill-hole-center-dot{fill:${centerDot};stroke:none;}`,
2411
+ `.decal-outline{fill:none;stroke:${stroke};stroke-width:.25;stroke-dasharray:2 1;}`,
2412
+ `.label{fill:${labelColor};font-family:Arial,sans-serif;font-size:2.6px;}`,
2413
+ ".muted{fill:#475569;font-family:Arial,sans-serif;font-size:2.3px;}",
2414
+ "</style>",
2415
+ "</defs>",
2416
+ ].join("");
2417
+ }
2418
+ function drillTemplateHeaderSvg(template) {
2419
+ if (template.mode !== "print") {
2420
+ return "";
2421
+ }
2422
+ return "";
2423
+ }
2424
+ function drillTemplateEnclosureSvg(template) {
2425
+ const layout = outsideDrillTemplateLayout(template.enclosure, template.canvasMm);
2426
+ const { top, left, right, back, bottom } = layout.panels;
2427
+ const topCenterX = top.x + top.width / 2;
2428
+ const topCenterY = top.y + top.height / 2;
2429
+ return [
2430
+ `<g ${svgAttributes([
2431
+ ["data-enclosure-id", template.enclosure.variantId],
2432
+ ["data-template-view", "outside-unfolded"],
2433
+ ])}>`,
2434
+ drillTemplatePanelSvg(back, "panel side-panel", template.appearance),
2435
+ drillTemplatePanelSvg(left, "panel side-panel", template.appearance),
2436
+ drillTemplatePanelSvg(right, "panel side-panel", template.appearance),
2437
+ drillTemplatePanelSvg(bottom, "panel side-panel", template.appearance),
2438
+ drillTemplatePanelSvg(top, "panel top-panel enclosure", template.appearance),
2439
+ drillTemplateFoldFillSvg(back),
2440
+ drillTemplateFoldFillSvg(left),
2441
+ drillTemplateFoldFillSvg(right),
2442
+ drillTemplateFoldFillSvg(bottom),
2443
+ drillTemplateLineSvg("fold-line", [
2444
+ ["data-fold-line", "left"],
2445
+ ["x1", top.x],
2446
+ ["y1", top.y],
2447
+ ["x2", top.x],
2448
+ ["y2", top.y + top.height],
2449
+ ], template.appearance),
2450
+ drillTemplateLineSvg("fold-line", [
2451
+ ["data-fold-line", "right"],
2452
+ ["x1", top.x + top.width],
2453
+ ["y1", top.y],
2454
+ ["x2", top.x + top.width],
2455
+ ["y2", top.y + top.height],
2456
+ ], template.appearance),
2457
+ drillTemplateLineSvg("fold-line", [
2458
+ ["data-fold-line", "back"],
2459
+ ["x1", top.x],
2460
+ ["y1", top.y],
2461
+ ["x2", top.x + top.width],
2462
+ ["y2", top.y],
2463
+ ], template.appearance),
2464
+ drillTemplateLineSvg("fold-line", [
2465
+ ["data-fold-line", "bottom"],
2466
+ ["x1", top.x],
2467
+ ["y1", top.y + top.height],
2468
+ ["x2", top.x + top.width],
2469
+ ["y2", top.y + top.height],
2470
+ ], template.appearance),
2471
+ drillTemplateLineSvg("guide-line", [
2472
+ ["data-template-guide", "vertical-centerline"],
2473
+ ["x1", topCenterX],
2474
+ ["y1", layout.panels.back.y],
2475
+ ["x2", topCenterX],
2476
+ ["y2", layout.panels.bottom.y + layout.panels.bottom.height],
2477
+ ], template.appearance),
2478
+ drillTemplateLineSvg("guide-line", [
2479
+ ["data-template-guide", "horizontal-centerline"],
2480
+ ["x1", layout.panels.left.x],
2481
+ ["y1", topCenterY],
2482
+ ["x2", layout.panels.right.x + layout.panels.right.width],
2483
+ ["y2", topCenterY],
2484
+ ], template.appearance),
2485
+ ...template.holes.map((hole) => drillTemplateHoleSvg(hole, template.mode, template.appearance)),
2486
+ "</g>",
2487
+ ].join("");
2488
+ }
2489
+ function drillTemplatePanelSvg(panel, className, appearance) {
2490
+ const isTop = panel.id === "top";
2491
+ return `<rect ${svgAttributes([
2492
+ ["class", className],
2493
+ ["data-face-panel", panel.id],
2494
+ ["x", svgNumber(panel.x)],
2495
+ ["y", svgNumber(panel.y)],
2496
+ ["width", svgNumber(panel.width)],
2497
+ ["height", svgNumber(panel.height)],
2498
+ ["rx", isTop ? 2.5 : 0],
2499
+ [
2500
+ "fill",
2501
+ isTop ? appearance?.enclosure?.color : appearance?.template?.offColor,
2502
+ ],
2503
+ ["stroke", appearance?.enclosure?.strokeColor],
2504
+ ])}/>`;
2505
+ }
2506
+ function drillTemplateFoldFillSvg(panel) {
2507
+ const inset = Math.min(panel.width, panel.height) > 12 ? 4 : 2;
2508
+ return `<rect ${svgAttributes([
2509
+ ["class", "fold-fill"],
2510
+ ["data-face-panel-fill", panel.id],
2511
+ ["x", svgNumber(panel.x + inset)],
2512
+ ["y", svgNumber(panel.y + inset)],
2513
+ ["width", svgNumber(Math.max(panel.width - inset * 2, 0))],
2514
+ ["height", svgNumber(Math.max(panel.height - inset * 2, 0))],
2515
+ ])}/>`;
2516
+ }
2517
+ function drillTemplateLineSvg(className, attributes, appearance) {
2518
+ const stroke = className === "guide-line"
2519
+ ? appearance?.template?.guideColor
2520
+ : className === "fold-line"
2521
+ ? appearance?.template?.foldColor
2522
+ : undefined;
2523
+ const normalized = attributes.map(([name, value]) => [name, typeof value === "number" ? svgNumber(value) : value]);
2524
+ return `<line ${svgAttributes([
2525
+ ["class", className],
2526
+ ["stroke", stroke],
2527
+ ...normalized,
2528
+ ])}/>`;
2529
+ }
2530
+ function drillTemplateHoleSvg(hole, mode, appearance) {
2531
+ const radius = hole.drillDiameterMm / 2;
2532
+ const visibleDiameter = partGeometryVisibleDiameterMm(hole.partGeometry);
2533
+ const profile = drillHoleProfileForHole(hole);
2534
+ const labelId = `label-${decalIdSegment(hole.id)}`;
2535
+ const labelAppearance = labelAppearanceFor(labelId, hole.controlId, appearance);
2536
+ const label = labelAppearance?.text ?? drillTemplateHoleLabel(hole);
2537
+ const labelY = drillTemplateHoleLabelY(hole, radius);
2538
+ const labelAttrs = svgAttributes([
2539
+ ["class", "label"],
2540
+ ["x", svgNumber(hole.templateCenterMm.x)],
2541
+ ["y", svgNumber(labelY)],
2542
+ ["text-anchor", "middle"],
2543
+ ["fill", labelAppearance?.color],
2544
+ ["font-family", labelAppearance?.fontFamily],
2545
+ [
2546
+ "font-size",
2547
+ labelAppearance?.fontSizeMm === undefined
2548
+ ? undefined
2549
+ : svgNumber(labelAppearance.fontSizeMm),
2550
+ ],
2551
+ ]);
2552
+ return [
2553
+ `<g ${svgAttributes([
2554
+ ["data-hole-id", hole.id],
2555
+ ["data-part-profile-id", hole.partId],
2556
+ ["data-face", hole.face],
2557
+ ["data-template-face", hole.face],
2558
+ ["data-provenance", hole.provenance],
2559
+ ["data-drill-diameter-mm", svgNumber(hole.drillDiameterMm)],
2560
+ ["data-drill-radius-mm", svgNumber(radius)],
2561
+ [
2562
+ "data-part-visible-diameter-mm",
2563
+ visibleDiameter === undefined ? undefined : svgNumber(visibleDiameter),
2564
+ ],
2565
+ ["data-drill-hole-profile-id", profile?.id],
2566
+ ["data-drill-hole-profile-label", profile?.label],
2567
+ [
2568
+ "data-drill-hole-profile-diameter-mm",
2569
+ profile === undefined ? undefined : svgNumber(profile.diameterMm),
2570
+ ],
2571
+ ["data-drill-hole-profile-fraction-inches", profile?.fractionInches],
2572
+ ])}>`,
2573
+ drillTemplateHoleMarkerSvg(hole, radius, profile, appearance),
2574
+ label === undefined
2575
+ ? ""
2576
+ : `<text ${labelAttrs}>${escapeText(label)}</text>`,
2577
+ "</g>",
2578
+ ].join("");
2579
+ }
2580
+ function drillTemplateHoleLabelY(hole, radius) {
2581
+ if (hole.partFamily === "dc-jack") {
2582
+ return hole.templateCenterMm.y + radius + 3.5;
2583
+ }
2584
+ return hole.templateCenterMm.y - radius - 1.8;
2585
+ }
2586
+ function drillTemplateHoleLabel(hole) {
2587
+ if (hole.partFamily === "footswitch") {
2588
+ return undefined;
2589
+ }
2590
+ return hole.label ?? hole.controlId ?? hole.id;
2591
+ }
2592
+ function drillTemplateHoleMarkerSvg(hole, radius, profile, appearance) {
2593
+ const dotRadius = drillTemplateCenterDotRadius(radius);
2594
+ const centerDot = `<circle ${svgAttributes([
2595
+ ["class", "drill-hole-center-dot"],
2596
+ ["cx", svgNumber(hole.templateCenterMm.x)],
2597
+ ["cy", svgNumber(hole.templateCenterMm.y)],
2598
+ ["r", svgNumber(dotRadius)],
2599
+ ["fill", appearance?.template?.centerDotColor],
2600
+ ])}/>`;
2601
+ if (profile?.marker === "center-dot") {
2602
+ return centerDot;
2603
+ }
2604
+ return [
2605
+ `<circle ${svgAttributes([
2606
+ ["class", "hole drill-hole-profile-outer"],
2607
+ ["cx", svgNumber(hole.templateCenterMm.x)],
2608
+ ["cy", svgNumber(hole.templateCenterMm.y)],
2609
+ ["r", svgNumber(radius)],
2610
+ ["fill", appearance?.template?.holeFillColor],
2611
+ ["stroke", appearance?.template?.holeStrokeColor],
2612
+ ])}/>`,
2613
+ centerDot,
2614
+ ].join("");
2615
+ }
2616
+ function drillTemplateCenterDotRadius(radius) {
2617
+ return Math.min(1.5, Math.max(0.55, radius * 0.25));
2618
+ }
2619
+ function drillHoleProfileForHole(hole) {
2620
+ if (hole.drillHoleProfileId !== undefined) {
2621
+ return STOMPBOX_DRILL_HOLE_PROFILE_CATALOG[hole.drillHoleProfileId];
2622
+ }
2623
+ return Object.values(STOMPBOX_DRILL_HOLE_PROFILE_CATALOG).find((profile) => Math.abs(profile.diameterMm - hole.drillDiameterMm) < 0.001);
2624
+ }
2625
+ function drillTemplateDecalsSvg(template) {
2626
+ if (template.decals.length === 0) {
2627
+ return "";
2628
+ }
2629
+ const layout = outsideDrillTemplateLayout(template.enclosure, template.canvasMm);
2630
+ return [
2631
+ '<g data-decal-outlines="true">',
2632
+ ...template.decals.map((decal) => drillTemplateDecalOutlineSvg(decal, layout, template.enclosure)),
2633
+ "</g>",
2634
+ ].join("");
2635
+ }
2636
+ function drillTemplateDecalOutlineSvg(decal, layout, enclosure) {
2637
+ const center = drillTemplateCenterForDecal(decal.face, decal.centerMm, layout, enclosure);
2638
+ return [
2639
+ `<g ${svgAttributes([
2640
+ ["data-decal-outline", true],
2641
+ ["data-decal-id", decal.id],
2642
+ ["data-decal-kind", decal.kind],
2643
+ ["data-face", decal.face],
2644
+ [
2645
+ "transform",
2646
+ `translate(${svgNumber(center.x)} ${svgNumber(center.y)}) rotate(${svgNumber(decal.rotationDeg)})`,
2647
+ ],
2648
+ ])}>`,
2649
+ `<rect class="decal-outline" x="${svgNumber(-decal.sizeMm.widthMm / 2)}" y="${svgNumber(-decal.sizeMm.heightMm / 2)}" width="${svgNumber(decal.sizeMm.widthMm)}" height="${svgNumber(decal.sizeMm.heightMm)}" rx=".8"/>`,
2650
+ "</g>",
2651
+ ].join("");
2652
+ }
2653
+ function drillTemplateScaleMarksSvg(template) {
2654
+ if (template.scaleMarks.length === 0) {
2655
+ return "";
2656
+ }
2657
+ return [
2658
+ '<g data-scale-marks="true">',
2659
+ ...template.scaleMarks.map((mark) => [
2660
+ `<g ${svgAttributes([
2661
+ ["data-scale-mark-id", mark.id],
2662
+ ["data-scale-mark-mm", mark.lengthMm],
2663
+ ])}>`,
2664
+ `<line x1="${svgNumber(mark.startMm.x)}" y1="${svgNumber(mark.startMm.y)}" x2="${svgNumber(mark.endMm.x)}" y2="${svgNumber(mark.endMm.y)}" stroke="#111827" stroke-width=".35"/>`,
2665
+ `<line x1="${svgNumber(mark.startMm.x)}" y1="${svgNumber(mark.startMm.y - 1.5)}" x2="${svgNumber(mark.startMm.x)}" y2="${svgNumber(mark.startMm.y + 1.5)}" stroke="#111827" stroke-width=".35"/>`,
2666
+ `<line x1="${svgNumber(mark.endMm.x)}" y1="${svgNumber(mark.endMm.y - 1.5)}" x2="${svgNumber(mark.endMm.x)}" y2="${svgNumber(mark.endMm.y + 1.5)}" stroke="#111827" stroke-width=".35"/>`,
2667
+ "</g>",
2668
+ ].join("")),
2669
+ "</g>",
2670
+ ].join("");
2671
+ }
2672
+ function drillTemplateHoleTableSvg(template) {
2673
+ if (template.holeTable.length === 0) {
2674
+ return "";
2675
+ }
2676
+ return "";
2677
+ }
2678
+ function previewViewSvg(preview, view, grain) {
2679
+ const canvas = previewViewCanvas(preview, view);
2680
+ const titleId = `stompbox-preview-${view}-title`;
2681
+ const descId = `stompbox-preview-${view}-desc`;
2682
+ const attrs = svgAttributes([
2683
+ ["xmlns", "http://www.w3.org/2000/svg"],
2684
+ ["role", "img"],
2685
+ ["aria-labelledby", `${titleId} ${descId}`],
2686
+ ["width", `${svgNumber(canvas.widthMm)}mm`],
2687
+ ["height", `${svgNumber(canvas.heightMm)}mm`],
2688
+ [
2689
+ "viewBox",
2690
+ `0 0 ${svgNumber(canvas.widthMm)} ${svgNumber(canvas.heightMm)}`,
2691
+ ],
2692
+ ["data-view", view],
2693
+ ["data-units", preview.units],
2694
+ ]);
2695
+ const parts = preview.parts
2696
+ .filter((part) => partVisibleInView(part, view))
2697
+ .map((part) => previewPartSvg(preview, part, view, canvas))
2698
+ .join("");
2699
+ const decals = preview.decals
2700
+ .filter((decal) => decalVisibleInView(decal, view))
2701
+ .map((decal) => previewDecalSvg(decal, canvas))
2702
+ .join("");
2703
+ return [
2704
+ `<svg ${attrs}>`,
2705
+ `<title id="${escapeAttribute(titleId)}">Stompbox preview ${escapeText(view)} view</title>`,
2706
+ `<desc id="${escapeAttribute(descId)}">Orthographic ${escapeText(view)} SVG preview for the stompbox assembly.</desc>`,
2707
+ previewViewDefsSvg(view, canvas, grain),
2708
+ previewFrameSvg(preview, view, canvas),
2709
+ decals,
2710
+ parts,
2711
+ previewSvgGrainOverlay(view, canvas, grain),
2712
+ "</svg>",
2713
+ ].join("");
2714
+ }
2715
+ function previewViewDefsSvg(view, canvas, grain) {
2716
+ return [
2717
+ "<defs>",
2718
+ "<style>.case{fill:#f8fafc;stroke:#334155;stroke-width:.35}.decal-bounds{fill:none;stroke:#475569;stroke-width:.18;stroke-dasharray:1.5 1}.grain-overlay{mix-blend-mode:soft-light}</style>",
2719
+ previewSvgGrainFilter(view, grain),
2720
+ previewSvgGrainClipPath(view, canvas, grain),
2721
+ "</defs>",
2722
+ ].join("");
2723
+ }
2724
+ function previewSvgGrainFilter(view, grain) {
2725
+ if (grain === undefined) {
2726
+ return "";
2727
+ }
2728
+ return [
2729
+ `<filter id="${escapeAttribute(previewSvgGrainFilterId(view))}" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">`,
2730
+ `<feTurbulence type="fractalNoise" baseFrequency="${svgNumber(grain.baseFrequency)}" numOctaves="${svgNumber(grain.numOctaves)}" stitchTiles="stitch" result="turbulence"/>`,
2731
+ '<feComposite operator="in" in="turbulence" in2="SourceAlpha" result="composite"/>',
2732
+ '<feColorMatrix in="composite" type="luminanceToAlpha" />',
2733
+ '<feBlend in="SourceGraphic" in2="composite" mode="screen" />',
2734
+ "</filter>",
2735
+ ].join("");
2736
+ }
2737
+ function previewSvgGrainOverlay(view, canvas, grain) {
2738
+ if (grain === undefined) {
2739
+ return "";
2740
+ }
2741
+ return `<rect class="grain-overlay" data-grain-overlay="true" x="0" y="0" width="${svgNumber(canvas.widthMm)}" height="${svgNumber(canvas.heightMm)}" fill="#808080" filter="url(#${escapeAttribute(previewSvgGrainFilterId(view))})" clip-path="url(#${escapeAttribute(previewSvgGrainClipPathId(view))})" pointer-events="none"${grain.opacity === 1 ? "" : ` opacity="${svgNumber(grain.opacity)}"`}/>`;
2742
+ }
2743
+ function previewSvgGrainFilterId(view) {
2744
+ return `stompbox-preview-${view}-noise-filter`;
2745
+ }
2746
+ function previewSvgGrainClipPath(view, canvas, grain) {
2747
+ if (grain === undefined) {
2748
+ return "";
2749
+ }
2750
+ return `<clipPath id="${escapeAttribute(previewSvgGrainClipPathId(view))}"><rect x="0" y="0" width="${svgNumber(canvas.widthMm)}" height="${svgNumber(canvas.heightMm)}" rx="2.5"/></clipPath>`;
2751
+ }
2752
+ function previewSvgGrainClipPathId(view) {
2753
+ return `stompbox-preview-${view}-grain-clip`;
2754
+ }
2755
+ function previewViewCanvas(preview, view) {
2756
+ if (view === "left" || view === "right") {
2757
+ return {
2758
+ widthMm: preview.enclosure.dimensionsMm.depthMm,
2759
+ heightMm: preview.enclosure.dimensionsMm.lengthMm,
2760
+ };
2761
+ }
2762
+ if (view === "back" || view === "bottom") {
2763
+ return {
2764
+ widthMm: preview.enclosure.dimensionsMm.widthMm,
2765
+ heightMm: preview.enclosure.dimensionsMm.depthMm,
2766
+ };
2767
+ }
2768
+ return {
2769
+ widthMm: preview.enclosure.dimensionsMm.widthMm,
2770
+ heightMm: preview.enclosure.dimensionsMm.lengthMm,
2771
+ };
2772
+ }
2773
+ function previewFrameSvg(preview, view, canvas) {
2774
+ const enclosureFill = preview.enclosure.material?.color ?? "#f8fafc";
2775
+ const enclosureStroke = preview.enclosure.material?.strokeColor ?? "#334155";
2776
+ return [
2777
+ `<g data-enclosure-id="${escapeAttribute(preview.enclosure.variantId)}" data-enclosure-view="${escapeAttribute(view)}">`,
2778
+ `<rect class="case" x="0" y="0" width="${svgNumber(canvas.widthMm)}" height="${svgNumber(canvas.heightMm)}" rx="2.5" fill="${escapeAttribute(enclosureFill)}" stroke="${escapeAttribute(enclosureStroke)}"/>`,
2779
+ view === "bottom"
2780
+ ? `<rect x="4" y="4" width="${svgNumber(canvas.widthMm - 8)}" height="${svgNumber(canvas.heightMm - 8)}" rx="2" fill="none" stroke="#94a3b8" stroke-width=".25" data-enclosure-bottom="true"/>`
2781
+ : "",
2782
+ "</g>",
2783
+ ].join("");
2784
+ }
2785
+ function previewPartSvg(preview, part, view, canvas) {
2786
+ const point = previewPointForPart(preview, part, view, canvas);
2787
+ const attrs = svgAttributes([
2788
+ ["data-part-id", part.id],
2789
+ ["data-part-profile-id", part.partId],
2790
+ ["data-part-family", part.family],
2791
+ ["data-control-id", part.controlId],
2792
+ ["data-face", part.face],
2793
+ ["data-provenance", part.provenance],
2794
+ [
2795
+ "data-knob-rotation-deg",
2796
+ part.geometry.kind === "knob" ? part.transform.rotationDeg.z : undefined,
2797
+ ],
2798
+ [
2799
+ "data-led-emissive",
2800
+ part.family === "led" ? part.material?.emissive === true : undefined,
2801
+ ],
2802
+ [
2803
+ "data-footswitch-pressed",
2804
+ part.geometry.kind === "footswitch"
2805
+ ? part.transform.translationMm.z <
2806
+ preview.enclosure.dimensionsMm.depthMm / 2
2807
+ : undefined,
2808
+ ],
2809
+ ]);
2810
+ return [`<g ${attrs}>`, previewPartShapeSvg(part, point), "</g>"].join("");
2811
+ }
2812
+ function previewDecalSvg(decal, canvas) {
2813
+ const point = previewPointForDecal(decal, canvas);
2814
+ const bounds = decal.id.startsWith("label-")
2815
+ ? ""
2816
+ : `<rect class="decal-bounds" x="${svgNumber(-decal.sizeMm.widthMm / 2)}" y="${svgNumber(-decal.sizeMm.heightMm / 2)}" width="${svgNumber(decal.sizeMm.widthMm)}" height="${svgNumber(decal.sizeMm.heightMm)}" rx=".8"/>`;
2817
+ return [
2818
+ `<g ${svgAttributes([
2819
+ ["data-decal-id", decal.id],
2820
+ ["data-decal-kind", decal.kind],
2821
+ ["data-face", decal.face],
2822
+ [
2823
+ "transform",
2824
+ `translate(${svgNumber(point.x)} ${svgNumber(point.y)}) rotate(${svgNumber(decal.rotationDeg)})`,
2825
+ ],
2826
+ ])}>`,
2827
+ bounds,
2828
+ previewDecalContentSvg(decal),
2829
+ "</g>",
2830
+ ].join("");
2831
+ }
2832
+ function previewDecalContentSvg(decal) {
2833
+ if (decal.kind === "text") {
2834
+ return `<text class="label-text" x="0" y="0" text-anchor="middle" dominant-baseline="middle" font-family="${escapeAttribute(decal.fontFamily)}" font-size="${svgNumber(decal.fontSizeMm)}" fill="${escapeAttribute(decal.color)}">${escapeText(decal.text)}</text>`;
2835
+ }
2836
+ const href = decal.kind === "svg"
2837
+ ? svgDataUri(colorizedSvg(decal.svg, decal.color))
2838
+ : decal.href;
2839
+ return `<image href="${escapeAttribute(href)}" x="${svgNumber(-decal.sizeMm.widthMm / 2)}" y="${svgNumber(-decal.sizeMm.heightMm / 2)}" width="${svgNumber(decal.sizeMm.widthMm)}" height="${svgNumber(decal.sizeMm.heightMm)}" preserveAspectRatio="xMidYMid meet"/>`;
2840
+ }
2841
+ function previewPartShapeSvg(part, point) {
2842
+ const geometry = part.geometry;
2843
+ if (geometry.kind === "knob") {
2844
+ const radius = geometry.diameterMm / 2;
2845
+ const fill = part.material?.color ?? "#334155";
2846
+ const stroke = part.material?.strokeColor ?? "#eb7223";
2847
+ const indicator = "#f8fafc";
2848
+ const svgIndicatorRotationDeg = -part.transform.rotationDeg.z;
2849
+ return [
2850
+ `<circle class="knob-body" cx="${svgNumber(point.x)}" cy="${svgNumber(point.y)}" r="${svgNumber(radius)}" fill="${escapeAttribute(fill)}" stroke="${escapeAttribute(stroke)}" stroke-width=".35"/>`,
2851
+ `<line class="knob-indicator" x1="${svgNumber(point.x)}" y1="${svgNumber(point.y)}" x2="${svgNumber(point.x)}" y2="${svgNumber(point.y - radius + 2)}" stroke="${escapeAttribute(indicator)}" stroke-width=".8" stroke-linecap="round" transform="rotate(${svgNumber(svgIndicatorRotationDeg)} ${svgNumber(point.x)} ${svgNumber(point.y)})"/>`,
2852
+ ].join("");
2853
+ }
2854
+ if (geometry.kind === "led") {
2855
+ const radius = geometry.flangeDiameterMm / 2;
2856
+ const fill = part.material?.emissive === true
2857
+ ? (part.material.color ?? "#ef4444")
2858
+ : (part.material?.offColor ?? "#fee2e2");
2859
+ const stroke = part.material?.strokeColor ?? "#7f1d1d";
2860
+ const opacity = part.material?.emissive === true ? "1" : ".45";
2861
+ return `<circle class="led-lens" cx="${svgNumber(point.x)}" cy="${svgNumber(point.y)}" r="${svgNumber(radius)}" fill="${escapeAttribute(fill)}" fill-opacity="${opacity}" stroke="${escapeAttribute(stroke)}" stroke-width=".3"/>`;
2862
+ }
2863
+ if (geometry.kind === "led-bezel") {
2864
+ const lensFill = part.material?.emissive === true
2865
+ ? (part.material.color ?? "#ef4444")
2866
+ : (part.material?.offColor ?? "#fee2e2");
2867
+ const lensStroke = part.material?.strokeColor ?? "#7f1d1d";
2868
+ const opacity = part.material?.emissive === true ? "1" : ".45";
2869
+ return [
2870
+ `<circle class="led-bezel-ring" cx="${svgNumber(point.x)}" cy="${svgNumber(point.y)}" r="${svgNumber(geometry.outerDiameterMm / 2)}" fill="#d1d5db" stroke="#64748b" stroke-width=".35"/>`,
2871
+ `<circle class="led-lens" cx="${svgNumber(point.x)}" cy="${svgNumber(point.y)}" r="${svgNumber(geometry.innerDiameterMm / 2)}" fill="${escapeAttribute(lensFill)}" fill-opacity="${opacity}" stroke="${escapeAttribute(lensStroke)}" stroke-width=".25"/>`,
2872
+ ].join("");
2873
+ }
2874
+ if (geometry.kind === "footswitch") {
2875
+ const pressed = part.transform.translationMm.z < 15.5;
2876
+ const nutFill = part.material?.color ?? "#d1d5db";
2877
+ const buttonFill = pressed
2878
+ ? (part.material?.pressedColor ?? "#64748b")
2879
+ : (part.material?.offColor ?? "#94a3b8");
2880
+ const stroke = part.material?.strokeColor ?? "#374151";
2881
+ return [
2882
+ `<circle class="footswitch-nut" cx="${svgNumber(point.x)}" cy="${svgNumber(point.y)}" r="${svgNumber(geometry.nutOuterDiameterMm / 2)}" fill="${escapeAttribute(nutFill)}" stroke="${escapeAttribute(stroke)}" stroke-width=".35"/>`,
2883
+ `<circle class="footswitch-button" cx="${svgNumber(point.x)}" cy="${svgNumber(point.y + (pressed ? 0.6 : 0))}" r="${svgNumber(geometry.buttonDiameterMm / 2)}" fill="${escapeAttribute(buttonFill)}" stroke="#1f2937" stroke-width=".25"/>`,
2884
+ ].join("");
2885
+ }
2886
+ const stroke = part.material?.strokeColor ?? "#334155";
2887
+ const innerStroke = part.material?.color ?? "#94a3b8";
2888
+ return [
2889
+ `<circle class="ring-outer" cx="${svgNumber(point.x)}" cy="${svgNumber(point.y)}" r="${svgNumber(geometry.outerDiameterMm / 2)}" fill="none" stroke="${escapeAttribute(stroke)}" stroke-width=".45"/>`,
2890
+ `<circle class="ring-inner" cx="${svgNumber(point.x)}" cy="${svgNumber(point.y)}" r="${svgNumber(geometry.innerDiameterMm / 2)}" fill="none" stroke="${escapeAttribute(innerStroke)}" stroke-width=".3"/>`,
2891
+ ].join("");
2892
+ }
2893
+ function previewPointForPart(preview, part, view, canvas) {
2894
+ if (view === "left" || view === "right") {
2895
+ return {
2896
+ x: canvas.widthMm / 2,
2897
+ y: canvas.heightMm / 2 - part.transform.translationMm.y,
2898
+ };
2899
+ }
2900
+ if (view === "back") {
2901
+ return {
2902
+ x: preview.enclosure.dimensionsMm.widthMm / 2 +
2903
+ part.transform.translationMm.x,
2904
+ y: canvas.heightMm / 2 - part.transform.translationMm.z,
2905
+ };
2906
+ }
2907
+ return {
2908
+ x: preview.enclosure.dimensionsMm.widthMm / 2 +
2909
+ part.transform.translationMm.x,
2910
+ y: preview.enclosure.dimensionsMm.lengthMm / 2 -
2911
+ part.transform.translationMm.y,
2912
+ };
2913
+ }
2914
+ function previewPointForDecal(decal, canvas) {
2915
+ const center = decalFaceLocalCenterMm(decal);
2916
+ return {
2917
+ x: canvas.widthMm / 2 + center.x,
2918
+ y: canvas.heightMm / 2 - center.y,
2919
+ };
2920
+ }
2921
+ function decalUsesFaceLocalCoordinates(decal) {
2922
+ return decal.placement !== undefined || !decal.id.startsWith("label-");
2923
+ }
2924
+ function partVisibleInView(part, view) {
2925
+ if (view === "top") {
2926
+ return part.face === "top";
2927
+ }
2928
+ if (view === "left") {
2929
+ return part.face === "left";
2930
+ }
2931
+ if (view === "right") {
2932
+ return part.face === "right";
2933
+ }
2934
+ if (view === "back") {
2935
+ return part.face === "back";
2936
+ }
2937
+ return part.face === "bottom";
2938
+ }
2939
+ function decalVisibleInView(decal, view) {
2940
+ if (view === "top") {
2941
+ return decal.face === "top";
2942
+ }
2943
+ if (view === "left") {
2944
+ return decal.face === "left";
2945
+ }
2946
+ if (view === "right") {
2947
+ return decal.face === "right";
2948
+ }
2949
+ if (view === "back") {
2950
+ return decal.face === "back";
2951
+ }
2952
+ return decal.face === "bottom";
2953
+ }
2954
+ function previewGlb(preview, options, assetValidation) {
2955
+ const appearance = createStompboxAppearancePatch(preview, options.appearance);
2956
+ const state = {
2957
+ sourceAssets: [],
2958
+ nodes: [
2959
+ {
2960
+ name: "stompbox-preview-root",
2961
+ children: [],
2962
+ extras: {
2963
+ schema: "stompbox-preview-glb/v1",
2964
+ units: preview.units,
2965
+ enclosureId: preview.enclosure.variantId,
2966
+ decals: preview.decals.map((decal) => previewDecalJson(decal)),
2967
+ appearance,
2968
+ },
2969
+ },
2970
+ ],
2971
+ meshes: [],
2972
+ materials: [],
2973
+ bufferViews: [],
2974
+ accessors: [],
2975
+ binaryParts: [],
2976
+ binaryByteLength: 0,
2977
+ };
2978
+ const rootChildren = [];
2979
+ for (const source of gltfAssemblySources(preview, options, assetValidation)) {
2980
+ rootChildren.push(appendAssemblySource(state, source));
2981
+ }
2982
+ for (const part of preview.parts) {
2983
+ const holeBacking = appendHoleBackingDiscForPart(state, part, preview.drillLayout);
2984
+ if (holeBacking !== undefined) {
2985
+ rootChildren.push(holeBacking);
2986
+ }
2987
+ }
2988
+ for (const decal of preview.decals) {
2989
+ rootChildren.push(appendDecalPlane(state, decal, preview.enclosure));
2990
+ }
2991
+ const rootNode = state.nodes[0];
2992
+ if (rootNode === undefined) {
2993
+ throw new Error("internal stompbox GLB assembly error: missing root node");
2994
+ }
2995
+ rootNode.children = rootChildren;
2996
+ const binary = concatUint8Arrays(state.binaryParts, state.binaryByteLength);
2997
+ const document = {
2998
+ asset: {
2999
+ version: "2.0",
3000
+ generator: "@vessel-dsp/stompbox",
3001
+ extras: {
3002
+ schema: "stompbox-preview-glb/v1",
3003
+ units: preview.units,
3004
+ sourceAssets: state.sourceAssets,
3005
+ decals: preview.decals.map((decal) => previewDecalJson(decal)),
3006
+ appearance,
3007
+ },
3008
+ },
3009
+ scene: 0,
3010
+ scenes: [{ name: "Stompbox Preview", nodes: [0] }],
3011
+ nodes: state.nodes,
3012
+ meshes: state.meshes,
3013
+ materials: state.materials,
3014
+ buffers: [{ byteLength: binary.byteLength }],
3015
+ bufferViews: state.bufferViews,
3016
+ accessors: state.accessors,
3017
+ };
3018
+ return encodeGlb(document, binary);
3019
+ }
3020
+ function gltfAssemblySources(preview, options, assetValidation) {
3021
+ if (options.basePath === undefined) {
3022
+ throw new Error("stompbox GLB assembly requires options.basePath for caller-provided asset files");
3023
+ }
3024
+ const basePath = options.basePath;
3025
+ return [
3026
+ {
3027
+ id: preview.enclosure.variantId,
3028
+ kind: "enclosure",
3029
+ displayGlb: preview.enclosure.assets.glb,
3030
+ displayStep: preview.enclosure.assets.step,
3031
+ localGlbPath: resolveStompboxAssetPaths(preview.drillLayout.enclosure.assets, { basePath }).glb,
3032
+ ...(preview.enclosure.material === undefined
3033
+ ? {}
3034
+ : { material: preview.enclosure.material }),
3035
+ transform: {
3036
+ translation: [0, 0, 0],
3037
+ rotation: [0, 0, 0, 1],
3038
+ },
3039
+ extras: {
3040
+ id: preview.enclosure.variantId,
3041
+ kind: "enclosure",
3042
+ glb: preview.enclosure.assets.glb,
3043
+ step: preview.enclosure.assets.step,
3044
+ dimensionsMm: {
3045
+ widthMm: preview.enclosure.dimensionsMm.widthMm,
3046
+ lengthMm: preview.enclosure.dimensionsMm.lengthMm,
3047
+ depthMm: preview.enclosure.dimensionsMm.depthMm,
3048
+ },
3049
+ ...(preview.enclosure.material === undefined
3050
+ ? {}
3051
+ : { material: previewMaterialJson(preview.enclosure.material) }),
3052
+ },
3053
+ },
3054
+ ...preview.parts.map((part) => partAssemblySource(part, preview.drillLayout, basePath, assetValidation?.assets[part.partId])),
3055
+ ];
3056
+ }
3057
+ function partAssemblySource(part, layout, basePath, validation) {
3058
+ const sourceAssets = sourceAssetRefsForPreviewPart(layout, part);
3059
+ const sourceMaterial = part.geometry.kind === "led-bezel" ? undefined : part.material;
3060
+ const transform = {
3061
+ translation: point3Array(part.transform.translationMm),
3062
+ rotation: quaternionFromEulerDeg(part.transform.rotationDeg),
3063
+ ...(part.assetScale === undefined
3064
+ ? {}
3065
+ : { scale: [part.assetScale, part.assetScale, part.assetScale] }),
3066
+ };
3067
+ const materialTargets = materialTargetsForPart(part);
3068
+ const stateTargets = resolvedStateTargetsForAssembly(part, validation);
3069
+ return {
3070
+ id: part.id,
3071
+ kind: "part",
3072
+ displayGlb: part.assets.glb,
3073
+ displayStep: part.assets.step,
3074
+ localGlbPath: resolveStompboxAssetPaths(sourceAssets, { basePath }).glb,
3075
+ ...(sourceMaterial === undefined ? {} : { material: sourceMaterial }),
3076
+ ...(materialTargets.length === 0 ? {} : { materialTargets }),
3077
+ ...(stateTargets === undefined ? {} : { stateTargets }),
3078
+ transform,
3079
+ extras: {
3080
+ id: part.id,
3081
+ kind: "part",
3082
+ partId: part.partId,
3083
+ face: part.face,
3084
+ provenance: part.provenance,
3085
+ glb: part.assets.glb,
3086
+ step: part.assets.step,
3087
+ ...(part.assetScale === undefined ? {} : { assetScale: part.assetScale }),
3088
+ ...(part.controlId === undefined ? {} : { controlId: part.controlId }),
3089
+ ...(part.material === undefined
3090
+ ? {}
3091
+ : { material: previewMaterialJson(part.material) }),
3092
+ ...(stateTargets === undefined
3093
+ ? {}
3094
+ : { stateTargets: partStateTargetsJson(stateTargets) }),
3095
+ },
3096
+ };
3097
+ }
3098
+ function sourceAssetRefsForPreviewPart(layout, part) {
3099
+ const hole = layout.holes.find((candidate) => candidate.id === part.id);
3100
+ if (hole === undefined) {
3101
+ throw new Error(`missing drill-layout source assets for stompbox part: ${part.id}`);
3102
+ }
3103
+ return hole.assets;
3104
+ }
3105
+ function materialTargetsForPart(part) {
3106
+ if (part.geometry.kind !== "led-bezel" || part.material === undefined) {
3107
+ return [];
3108
+ }
3109
+ const lensSelector = part.stateTargets?.led?.lens.selector;
3110
+ const meshNameIncludes = lensSelector?.meshNameIncludes ?? lensSelector?.meshName;
3111
+ if (meshNameIncludes === undefined) {
3112
+ return [];
3113
+ }
3114
+ return [
3115
+ {
3116
+ meshNameIncludes,
3117
+ material: part.material,
3118
+ },
3119
+ ];
3120
+ }
3121
+ function resolvedStateTargetsForAssembly(part, validation) {
3122
+ const ledLens = validation?.targets["led.lens"];
3123
+ if (part.family === "led" && ledLens !== undefined) {
3124
+ return {
3125
+ led: {
3126
+ lens: prefixResolvedStateTarget(part.id, ledLens),
3127
+ },
3128
+ };
3129
+ }
3130
+ const actuator = validation?.targets["footswitch.actuator"];
3131
+ if (part.family === "footswitch" && actuator !== undefined) {
3132
+ return {
3133
+ footswitch: {
3134
+ actuator: prefixResolvedStateTarget(part.id, actuator),
3135
+ },
3136
+ };
3137
+ }
3138
+ return undefined;
3139
+ }
3140
+ function prefixResolvedStateTarget(previewPartId, target) {
3141
+ return {
3142
+ ...target,
3143
+ nodeName: `${previewPartId}/${target.nodeName}`,
3144
+ ...(target.meshName === undefined
3145
+ ? {}
3146
+ : { meshName: `${previewPartId}/${target.meshName}` }),
3147
+ ...(target.materialName === undefined
3148
+ ? {}
3149
+ : { materialName: `${previewPartId}/${target.materialName}` }),
3150
+ };
3151
+ }
3152
+ function partStateTargetsJson(targets) {
3153
+ return cloneJsonObject(targets);
3154
+ }
3155
+ function previewMaterialJson(material) {
3156
+ return {
3157
+ ...(material.color === undefined ? {} : { color: material.color }),
3158
+ ...(material.strokeColor === undefined
3159
+ ? {}
3160
+ : { strokeColor: material.strokeColor }),
3161
+ ...(material.offColor === undefined ? {} : { offColor: material.offColor }),
3162
+ ...(material.pressedColor === undefined
3163
+ ? {}
3164
+ : { pressedColor: material.pressedColor }),
3165
+ ...(material.emissive === undefined ? {} : { emissive: material.emissive }),
3166
+ ...(material.intensity === undefined
3167
+ ? {}
3168
+ : { intensity: material.intensity }),
3169
+ ...(material.metallicFactor === undefined
3170
+ ? {}
3171
+ : { metallicFactor: material.metallicFactor }),
3172
+ ...(material.roughnessFactor === undefined
3173
+ ? {}
3174
+ : { roughnessFactor: material.roughnessFactor }),
3175
+ ...(material.opacity === undefined ? {} : { opacity: material.opacity }),
3176
+ };
3177
+ }
3178
+ function previewDecalJson(decal) {
3179
+ return {
3180
+ id: decal.id,
3181
+ kind: "decal",
3182
+ decalKind: decal.kind,
3183
+ face: decal.face,
3184
+ centerMm: {
3185
+ x: decal.centerMm.x,
3186
+ y: decal.centerMm.y,
3187
+ },
3188
+ sizeMm: {
3189
+ widthMm: decal.sizeMm.widthMm,
3190
+ heightMm: decal.sizeMm.heightMm,
3191
+ },
3192
+ rotationDeg: decal.rotationDeg,
3193
+ ...(decal.placement === undefined
3194
+ ? {}
3195
+ : { placement: previewDecalPlacementJson(decal.placement) }),
3196
+ ...previewDecalContentJson(decal),
3197
+ };
3198
+ }
3199
+ function previewDecalContentJson(decal) {
3200
+ if (decal.kind === "text") {
3201
+ return {
3202
+ text: decal.text,
3203
+ color: decal.color,
3204
+ fontFamily: decal.fontFamily,
3205
+ fontSizeMm: decal.fontSizeMm,
3206
+ };
3207
+ }
3208
+ if (decal.kind === "image") {
3209
+ return {
3210
+ href: decal.href,
3211
+ ...(decal.mimeType === undefined ? {} : { mimeType: decal.mimeType }),
3212
+ ...(decal.color === undefined ? {} : { color: decal.color }),
3213
+ };
3214
+ }
3215
+ return {
3216
+ svg: decal.svg,
3217
+ ...(decal.color === undefined ? {} : { color: decal.color }),
3218
+ };
3219
+ }
3220
+ function previewDecalPlacementJson(placement) {
3221
+ if (placement.kind === "grid") {
3222
+ return {
3223
+ kind: "grid",
3224
+ columns: placement.columns,
3225
+ rows: placement.rows,
3226
+ column: placement.column,
3227
+ row: placement.row,
3228
+ };
3229
+ }
3230
+ return {};
3231
+ }
3232
+ function appendDecalPlane(state, decal, enclosure) {
3233
+ const materialIndex = state.materials.length;
3234
+ state.materials.push({
3235
+ name: `decal-${decal.id}/material`,
3236
+ alphaMode: "BLEND",
3237
+ doubleSided: true,
3238
+ pbrMetallicRoughness: {
3239
+ baseColorFactor: [1, 1, 1, 0],
3240
+ metallicFactor: 0,
3241
+ roughnessFactor: 1,
3242
+ },
3243
+ });
3244
+ const positionAccessor = appendDecalPositionAccessor(state, decal.sizeMm);
3245
+ const indexAccessor = appendDecalIndexAccessor(state);
3246
+ const meshIndex = state.meshes.length;
3247
+ state.meshes.push({
3248
+ name: `decal-${decal.id}/plane`,
3249
+ primitives: [
3250
+ {
3251
+ attributes: { POSITION: positionAccessor },
3252
+ indices: indexAccessor,
3253
+ material: materialIndex,
3254
+ mode: 4,
3255
+ },
3256
+ ],
3257
+ });
3258
+ const transform = decalTransform(decal, enclosure);
3259
+ const nodeIndex = state.nodes.length;
3260
+ state.nodes.push({
3261
+ name: `decal-${decal.id}`,
3262
+ mesh: meshIndex,
3263
+ translation: point3Array(transform.translationMm),
3264
+ rotation: quaternionFromEulerDeg(transform.rotationDeg),
3265
+ extras: previewDecalJson(decal),
3266
+ });
3267
+ return nodeIndex;
3268
+ }
3269
+ function appendHoleBackingDiscForPart(state, part, layout) {
3270
+ if (part.geometry.kind !== "ring" ||
3271
+ (part.family !== "audio-jack" && part.family !== "dc-jack")) {
3272
+ return undefined;
3273
+ }
3274
+ const materialIndex = holeBackingMaterialIndex(state);
3275
+ const diameterMm = holeBackingDiameterMm(part, layout.holes.find((hole) => hole.id === part.id));
3276
+ const meshIndex = state.meshes.length;
3277
+ state.meshes.push({
3278
+ name: `hole-backing-${part.id}/disc`,
3279
+ primitives: [
3280
+ {
3281
+ attributes: {
3282
+ POSITION: appendDiscPositionAccessor(state, diameterMm / 2, STOMPBOX_HOLE_BACKING_OUTSET_MM),
3283
+ },
3284
+ indices: appendDiscIndexAccessor(state),
3285
+ material: materialIndex,
3286
+ mode: 4,
3287
+ },
3288
+ ],
3289
+ });
3290
+ const nodeIndex = state.nodes.length;
3291
+ state.nodes.push({
3292
+ name: `hole-backing-${part.id}`,
3293
+ mesh: meshIndex,
3294
+ translation: point3Array(part.transform.translationMm),
3295
+ rotation: quaternionFromEulerDeg(part.transform.rotationDeg),
3296
+ extras: {
3297
+ kind: "hole-backing",
3298
+ partId: part.id,
3299
+ sourcePartId: part.partId,
3300
+ face: part.face,
3301
+ diameterMm,
3302
+ outsetMm: STOMPBOX_HOLE_BACKING_OUTSET_MM,
3303
+ },
3304
+ });
3305
+ return nodeIndex;
3306
+ }
3307
+ function holeBackingDiameterMm(part, hole) {
3308
+ if (part.geometry.kind !== "ring") {
3309
+ return 0;
3310
+ }
3311
+ if (part.family === "dc-jack") {
3312
+ const drillDiameterMm = hole?.drillDiameterMm;
3313
+ if (drillDiameterMm !== undefined) {
3314
+ return Math.max(part.geometry.innerDiameterMm, drillDiameterMm - STOMPBOX_DC_JACK_HOLE_BACKING_INSET_MM);
3315
+ }
3316
+ return part.geometry.innerDiameterMm;
3317
+ }
3318
+ return part.geometry.innerDiameterMm;
3319
+ }
3320
+ function holeBackingMaterialIndex(state) {
3321
+ const existingIndex = state.materials.findIndex((material) => material.name === "hole-backing/material");
3322
+ if (existingIndex >= 0) {
3323
+ return existingIndex;
3324
+ }
3325
+ const materialIndex = state.materials.length;
3326
+ state.materials.push({
3327
+ name: "hole-backing/material",
3328
+ doubleSided: true,
3329
+ pbrMetallicRoughness: {
3330
+ baseColorFactor: [0, 0, 0, 1],
3331
+ metallicFactor: 0,
3332
+ roughnessFactor: 1,
3333
+ },
3334
+ extras: {
3335
+ kind: "hole-backing-material",
3336
+ },
3337
+ });
3338
+ return materialIndex;
3339
+ }
3340
+ function appendDiscPositionAccessor(state, radiusMm, zMm) {
3341
+ const segmentCount = 48;
3342
+ const positions = new Float32Array((segmentCount + 1) * 3);
3343
+ positions[0] = 0;
3344
+ positions[1] = 0;
3345
+ positions[2] = zMm;
3346
+ for (let index = 0; index < segmentCount; index += 1) {
3347
+ const angle = (Math.PI * 2 * index) / segmentCount;
3348
+ const offset = (index + 1) * 3;
3349
+ positions[offset] = Math.cos(angle) * radiusMm;
3350
+ positions[offset + 1] = Math.sin(angle) * radiusMm;
3351
+ positions[offset + 2] = zMm;
3352
+ }
3353
+ const bufferViewIndex = state.bufferViews.length;
3354
+ state.bufferViews.push({
3355
+ buffer: 0,
3356
+ byteOffset: appendBinaryChunk(state, typedArrayBytes(positions)),
3357
+ byteLength: positions.byteLength,
3358
+ target: 34962,
3359
+ });
3360
+ const accessorIndex = state.accessors.length;
3361
+ state.accessors.push({
3362
+ bufferView: bufferViewIndex,
3363
+ componentType: 5126,
3364
+ count: segmentCount + 1,
3365
+ type: "VEC3",
3366
+ min: [-radiusMm, -radiusMm, zMm],
3367
+ max: [radiusMm, radiusMm, zMm],
3368
+ });
3369
+ return accessorIndex;
3370
+ }
3371
+ function appendDiscIndexAccessor(state) {
3372
+ const segmentCount = 48;
3373
+ const indices = new Uint16Array(segmentCount * 3);
3374
+ for (let index = 0; index < segmentCount; index += 1) {
3375
+ const offset = index * 3;
3376
+ indices[offset] = 0;
3377
+ indices[offset + 1] = index + 1;
3378
+ indices[offset + 2] = index === segmentCount - 1 ? 1 : index + 2;
3379
+ }
3380
+ const bufferViewIndex = state.bufferViews.length;
3381
+ state.bufferViews.push({
3382
+ buffer: 0,
3383
+ byteOffset: appendBinaryChunk(state, typedArrayBytes(indices)),
3384
+ byteLength: indices.byteLength,
3385
+ target: 34963,
3386
+ });
3387
+ const accessorIndex = state.accessors.length;
3388
+ state.accessors.push({
3389
+ bufferView: bufferViewIndex,
3390
+ componentType: 5123,
3391
+ count: indices.length,
3392
+ type: "SCALAR",
3393
+ });
3394
+ return accessorIndex;
3395
+ }
3396
+ function appendDecalPositionAccessor(state, sizeMm) {
3397
+ const halfWidth = sizeMm.widthMm / 2;
3398
+ const halfHeight = sizeMm.heightMm / 2;
3399
+ const positions = new Float32Array([
3400
+ -halfWidth,
3401
+ -halfHeight,
3402
+ 0,
3403
+ halfWidth,
3404
+ -halfHeight,
3405
+ 0,
3406
+ halfWidth,
3407
+ halfHeight,
3408
+ 0,
3409
+ -halfWidth,
3410
+ halfHeight,
3411
+ 0,
3412
+ ]);
3413
+ const bufferViewIndex = state.bufferViews.length;
3414
+ state.bufferViews.push({
3415
+ buffer: 0,
3416
+ byteOffset: appendBinaryChunk(state, typedArrayBytes(positions)),
3417
+ byteLength: positions.byteLength,
3418
+ target: 34962,
3419
+ });
3420
+ const accessorIndex = state.accessors.length;
3421
+ state.accessors.push({
3422
+ bufferView: bufferViewIndex,
3423
+ componentType: 5126,
3424
+ count: 4,
3425
+ type: "VEC3",
3426
+ min: [-halfWidth, -halfHeight, 0],
3427
+ max: [halfWidth, halfHeight, 0],
3428
+ });
3429
+ return accessorIndex;
3430
+ }
3431
+ function appendDecalIndexAccessor(state) {
3432
+ const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
3433
+ const bufferViewIndex = state.bufferViews.length;
3434
+ state.bufferViews.push({
3435
+ buffer: 0,
3436
+ byteOffset: appendBinaryChunk(state, typedArrayBytes(indices)),
3437
+ byteLength: indices.byteLength,
3438
+ target: 34963,
3439
+ });
3440
+ const accessorIndex = state.accessors.length;
3441
+ state.accessors.push({
3442
+ bufferView: bufferViewIndex,
3443
+ componentType: 5123,
3444
+ count: 6,
3445
+ type: "SCALAR",
3446
+ });
3447
+ return accessorIndex;
3448
+ }
3449
+ function decalTransform(decal, enclosure) {
3450
+ const translation = decalTranslationForFace(decal, enclosure);
3451
+ const rotation = decalBaseRotationForFace(decal.face);
3452
+ return {
3453
+ translationMm: translation,
3454
+ rotationDeg: {
3455
+ ...rotation,
3456
+ z: rotation.z + decal.rotationDeg,
3457
+ },
3458
+ };
3459
+ }
3460
+ function decalTranslationForFace(decal, enclosure) {
3461
+ const { widthMm, lengthMm, depthMm } = enclosure.dimensionsMm;
3462
+ const center = decalFaceLocalCenterMm(decal);
3463
+ if (decal.face === "left") {
3464
+ return {
3465
+ x: -widthMm / 2 - STOMPBOX_DECAL_OUTSET_MM,
3466
+ y: center.y,
3467
+ z: center.x,
3468
+ };
3469
+ }
3470
+ if (decal.face === "right") {
3471
+ return {
3472
+ x: widthMm / 2 + STOMPBOX_DECAL_OUTSET_MM,
3473
+ y: center.y,
3474
+ z: center.x,
3475
+ };
3476
+ }
3477
+ if (decal.face === "back") {
3478
+ return {
3479
+ x: center.x,
3480
+ y: lengthMm / 2 + STOMPBOX_DECAL_OUTSET_MM,
3481
+ z: center.y,
3482
+ };
3483
+ }
3484
+ if (decal.face === "bottom") {
3485
+ return {
3486
+ x: center.x,
3487
+ y: -lengthMm / 2 - STOMPBOX_DECAL_OUTSET_MM,
3488
+ z: center.y,
3489
+ };
3490
+ }
3491
+ return {
3492
+ x: center.x,
3493
+ y: center.y,
3494
+ z: depthMm / 2 + STOMPBOX_DECAL_OUTSET_MM,
3495
+ };
3496
+ }
3497
+ function decalBaseRotationForFace(face) {
3498
+ if (face === "bottom") {
3499
+ return { x: 90, y: 0, z: 0 };
3500
+ }
3501
+ return baseRotationForFace(face);
3502
+ }
3503
+ function decalFaceLocalCenterMm(decal) {
3504
+ if ((decal.face === "left" || decal.face === "right") &&
3505
+ !decalUsesFaceLocalCoordinates(decal)) {
3506
+ return { x: 0, y: decal.centerMm.y };
3507
+ }
3508
+ return decal.centerMm;
3509
+ }
3510
+ function hexColorToRgb(color) {
3511
+ const match = /^#([0-9a-f]{6})$/i.exec(color);
3512
+ if (match?.[1] === undefined) {
3513
+ return [0.067, 0.094, 0.153];
3514
+ }
3515
+ const value = match[1];
3516
+ return [
3517
+ Number.parseInt(value.slice(0, 2), 16) / 255,
3518
+ Number.parseInt(value.slice(2, 4), 16) / 255,
3519
+ Number.parseInt(value.slice(4, 6), 16) / 255,
3520
+ ];
3521
+ }
3522
+ function typedArrayBytes(array) {
3523
+ return new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
3524
+ }
3525
+ function appendAssemblySource(state, source) {
3526
+ state.sourceAssets.push({
3527
+ id: source.id,
3528
+ kind: source.kind,
3529
+ glb: source.displayGlb,
3530
+ step: source.displayStep,
3531
+ });
3532
+ const wrapperIndex = state.nodes.length;
3533
+ const wrapper = {
3534
+ name: `${source.kind === "enclosure" ? "enclosure" : "part"}-${source.id}`,
3535
+ translation: source.transform.translation,
3536
+ rotation: source.transform.rotation,
3537
+ ...(source.transform.scale === undefined
3538
+ ? {}
3539
+ : { scale: source.transform.scale }),
3540
+ children: [],
3541
+ extras: source.extras,
3542
+ };
3543
+ state.nodes.push(wrapper);
3544
+ const sourceScaleIndex = state.nodes.length;
3545
+ const sourceScaleNode = {
3546
+ name: `source-${source.id}`,
3547
+ scale: [1000, 1000, 1000],
3548
+ children: [],
3549
+ extras: {
3550
+ sourceGlb: source.displayGlb,
3551
+ sourceUnits: "m",
3552
+ outputUnits: "mm",
3553
+ },
3554
+ };
3555
+ state.nodes.push(sourceScaleNode);
3556
+ wrapper.children = [sourceScaleIndex];
3557
+ sourceScaleNode.children = appendSourceGlb(state, source);
3558
+ return wrapperIndex;
3559
+ }
3560
+ function appendSourceGlb(state, source) {
3561
+ const parsed = parseGlbFile(source.localGlbPath);
3562
+ const bufferOffset = appendBinaryChunk(state, parsed.binary.slice(0, parsed.bufferByteLength));
3563
+ const sourceMaterials = jsonObjectArray(parsed.json, "materials");
3564
+ const bufferViewOffset = state.bufferViews.length;
3565
+ const accessorOffset = state.accessors.length;
3566
+ const materialOffset = state.materials.length;
3567
+ const meshOffset = state.meshes.length;
3568
+ const nodeOffset = state.nodes.length;
3569
+ for (const material of sourceMaterials) {
3570
+ state.materials.push(applyGltfMaterialAppearance(prefixNamedObject(material, `${source.id}/`), source.material));
3571
+ }
3572
+ for (const bufferView of jsonObjectArray(parsed.json, "bufferViews")) {
3573
+ state.bufferViews.push(remapBufferView(bufferView, bufferOffset));
3574
+ }
3575
+ for (const accessor of jsonObjectArray(parsed.json, "accessors")) {
3576
+ state.accessors.push(remapAccessor(accessor, bufferViewOffset));
3577
+ }
3578
+ for (const mesh of jsonObjectArray(parsed.json, "meshes")) {
3579
+ const target = materialTargetForMesh(source, mesh);
3580
+ state.meshes.push(remapMesh(mesh, accessorOffset, materialOffset, `${source.id}/`, target === undefined
3581
+ ? undefined
3582
+ : (primitive) => appendTargetMaterial(state, source, mesh, primitive, sourceMaterials, target)));
3583
+ }
3584
+ for (const node of jsonObjectArray(parsed.json, "nodes")) {
3585
+ state.nodes.push(remapNode(node, nodeOffset, meshOffset, `${source.id}/`));
3586
+ }
3587
+ return sourceSceneRootNodeIndexes(parsed.json).map((nodeIndex) => nodeIndex + nodeOffset);
3588
+ }
3589
+ function applyGltfMaterialAppearance(material, appearance) {
3590
+ if (appearance === undefined) {
3591
+ return material;
3592
+ }
3593
+ const pbr = {
3594
+ ...(jsonObjectValue(material.pbrMetallicRoughness) ?? {}),
3595
+ };
3596
+ if (appearance.color !== undefined || appearance.opacity !== undefined) {
3597
+ const color = hexColorToRgb(appearance.color ?? "#0f172a");
3598
+ pbr.baseColorFactor = [...color, appearance.opacity ?? 1];
3599
+ }
3600
+ if (appearance.metallicFactor !== undefined) {
3601
+ pbr.metallicFactor = appearance.metallicFactor;
3602
+ }
3603
+ if (appearance.roughnessFactor !== undefined) {
3604
+ pbr.roughnessFactor = appearance.roughnessFactor;
3605
+ }
3606
+ if (Object.keys(pbr).length > 0) {
3607
+ material.pbrMetallicRoughness = pbr;
3608
+ }
3609
+ if (appearance.emissive === true) {
3610
+ const color = hexColorToRgb(appearance.color ?? "#ef4444");
3611
+ const intensity = appearance.intensity ?? 1;
3612
+ material.emissiveFactor = color.map((channel) => channel * intensity);
3613
+ }
3614
+ if (appearance.color !== undefined) {
3615
+ material.extras = {
3616
+ ...(jsonObjectValue(material.extras) ?? {}),
3617
+ appearanceMaterial: previewMaterialJson(appearance),
3618
+ renderColorMode: "flat-color",
3619
+ };
3620
+ }
3621
+ return material;
3622
+ }
3623
+ function materialTargetForMesh(source, mesh) {
3624
+ const meshName = typeof mesh.name === "string" ? mesh.name : "";
3625
+ return source.materialTargets?.find((target) => meshName.includes(target.meshNameIncludes));
3626
+ }
3627
+ function appendTargetMaterial(state, source, mesh, primitive, sourceMaterials, target) {
3628
+ const sourceMaterial = typeof primitive.material === "number"
3629
+ ? sourceMaterials[primitive.material]
3630
+ : undefined;
3631
+ const material = cloneJsonObject(sourceMaterial ?? {});
3632
+ const meshName = typeof mesh.name === "string" ? mesh.name : "unnamed";
3633
+ material.name = `${source.id}/${meshName}/material`;
3634
+ const materialIndex = state.materials.length;
3635
+ state.materials.push(applyGltfMaterialAppearance(material, target.material));
3636
+ return materialIndex;
3637
+ }
3638
+ function remapBufferView(bufferView, byteOffset) {
3639
+ const copy = cloneJsonObject(bufferView);
3640
+ copy.buffer = 0;
3641
+ copy.byteOffset = numberValue(copy.byteOffset) + byteOffset;
3642
+ return copy;
3643
+ }
3644
+ function remapAccessor(accessor, bufferViewOffset) {
3645
+ const copy = cloneJsonObject(accessor);
3646
+ const bufferView = numberValue(copy.bufferView);
3647
+ copy.bufferView = bufferView + bufferViewOffset;
3648
+ return copy;
3649
+ }
3650
+ function remapMesh(mesh, accessorOffset, materialOffset, namePrefix, materialForPrimitive) {
3651
+ const copy = prefixNamedObject(mesh, namePrefix);
3652
+ const primitives = jsonObjectArray(mesh, "primitives").map((primitive, primitiveIndex) => remapPrimitive(primitive, accessorOffset, materialOffset, materialForPrimitive?.(primitive, primitiveIndex)));
3653
+ copy.primitives = primitives;
3654
+ return copy;
3655
+ }
3656
+ function remapPrimitive(primitive, accessorOffset, materialOffset, materialIndex) {
3657
+ const copy = cloneJsonObject(primitive);
3658
+ const attributes = jsonObjectValue(primitive.attributes);
3659
+ if (attributes !== undefined) {
3660
+ copy.attributes = remapAccessorMap(attributes, accessorOffset);
3661
+ }
3662
+ const indices = numberValue(copy.indices);
3663
+ if (copy.indices !== undefined) {
3664
+ copy.indices = indices + accessorOffset;
3665
+ }
3666
+ const material = numberValue(copy.material);
3667
+ if (copy.material !== undefined) {
3668
+ copy.material = materialIndex ?? material + materialOffset;
3669
+ }
3670
+ const targets = jsonArrayValue(primitive.targets);
3671
+ if (targets !== undefined) {
3672
+ copy.targets = targets.map((target) => jsonObjectValue(target) === undefined
3673
+ ? target
3674
+ : remapAccessorMap(jsonObjectValue(target) ?? {}, accessorOffset));
3675
+ }
3676
+ return copy;
3677
+ }
3678
+ function remapAccessorMap(map, accessorOffset) {
3679
+ const copy = {};
3680
+ for (const [key, value] of Object.entries(map)) {
3681
+ copy[key] =
3682
+ typeof value === "number"
3683
+ ? value + accessorOffset
3684
+ : cloneJsonValue(value);
3685
+ }
3686
+ return copy;
3687
+ }
3688
+ function remapNode(node, nodeOffset, meshOffset, namePrefix) {
3689
+ const copy = prefixNamedObject(node, namePrefix);
3690
+ if (copy.mesh !== undefined) {
3691
+ copy.mesh = numberValue(copy.mesh) + meshOffset;
3692
+ }
3693
+ const children = jsonArrayValue(copy.children);
3694
+ if (children !== undefined) {
3695
+ copy.children = children.flatMap((child) => typeof child === "number" ? [child + nodeOffset] : []);
3696
+ }
3697
+ return copy;
3698
+ }
3699
+ function prefixNamedObject(object, namePrefix) {
3700
+ const copy = cloneJsonObject(object);
3701
+ const name = typeof copy.name === "string" ? copy.name : "unnamed";
3702
+ copy.name = `${namePrefix}${name}`;
3703
+ return copy;
3704
+ }
3705
+ function sourceSceneRootNodeIndexes(json) {
3706
+ const sceneIndex = numberValue(json.scene);
3707
+ const scenes = jsonObjectArray(json, "scenes");
3708
+ const scene = scenes[sceneIndex] ?? scenes[0];
3709
+ if (scene === undefined) {
3710
+ return jsonObjectArray(json, "nodes").map((_node, index) => index);
3711
+ }
3712
+ const nodes = jsonArrayValue(scene.nodes);
3713
+ if (nodes === undefined) {
3714
+ return [];
3715
+ }
3716
+ return nodes.flatMap((node) => (typeof node === "number" ? [node] : []));
3717
+ }
3718
+ function parseGlbFile(path) {
3719
+ const bytes = new Uint8Array(readFileSync(path));
3720
+ return parseGlbBytes(bytes, path);
3721
+ }
3722
+ function parseGlbBytes(bytes, context) {
3723
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3724
+ if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2) {
3725
+ throw new Error(`not a glTF 2.0 binary file: ${context}`);
3726
+ }
3727
+ let json;
3728
+ let binary = new Uint8Array();
3729
+ let offset = 12;
3730
+ while (offset < bytes.byteLength) {
3731
+ const chunkLength = view.getUint32(offset, true);
3732
+ const chunkType = view.getUint32(offset + 4, true);
3733
+ const chunkStart = offset + 8;
3734
+ const chunk = bytes.slice(chunkStart, chunkStart + chunkLength);
3735
+ if (chunkType === 0x4e4f534a) {
3736
+ json = parseJsonObject(new TextDecoder().decode(chunk).trim(), context);
3737
+ }
3738
+ else if (chunkType === 0x004e4942) {
3739
+ binary = chunk;
3740
+ }
3741
+ offset = chunkStart + chunkLength;
3742
+ }
3743
+ if (json === undefined) {
3744
+ throw new Error(`GLB file has no JSON chunk: ${context}`);
3745
+ }
3746
+ return {
3747
+ json,
3748
+ binary,
3749
+ bufferByteLength: sourceBufferByteLength(json, binary),
3750
+ };
3751
+ }
3752
+ function sourceBufferByteLength(json, binary) {
3753
+ const buffers = jsonObjectArray(json, "buffers");
3754
+ const first = buffers[0];
3755
+ const byteLength = first === undefined ? 0 : numberValue(first.byteLength);
3756
+ if (byteLength > 0) {
3757
+ return Math.min(byteLength, binary.byteLength);
3758
+ }
3759
+ return binary.byteLength;
3760
+ }
3761
+ function appendBinaryChunk(state, bytes) {
3762
+ const alignedOffset = align4(state.binaryByteLength);
3763
+ if (alignedOffset > state.binaryByteLength) {
3764
+ state.binaryParts.push(new Uint8Array(alignedOffset - state.binaryByteLength));
3765
+ state.binaryByteLength = alignedOffset;
3766
+ }
3767
+ const offset = state.binaryByteLength;
3768
+ state.binaryParts.push(bytes);
3769
+ state.binaryByteLength += bytes.byteLength;
3770
+ return offset;
3771
+ }
3772
+ function concatUint8Arrays(parts, totalLength) {
3773
+ const result = new Uint8Array(totalLength);
3774
+ let offset = 0;
3775
+ for (const part of parts) {
3776
+ result.set(part, offset);
3777
+ offset += part.byteLength;
3778
+ }
3779
+ return result;
3780
+ }
3781
+ function parseJsonObject(source, context) {
3782
+ const parsed = JSON.parse(source);
3783
+ if (!isUnknownRecord(parsed)) {
3784
+ throw new Error(`GLB JSON chunk is not an object: ${context}`);
3785
+ }
3786
+ return parsed;
3787
+ }
3788
+ function jsonObjectArray(object, key) {
3789
+ const value = object[key];
3790
+ if (!Array.isArray(value)) {
3791
+ return [];
3792
+ }
3793
+ return value.flatMap((item) => jsonObjectValue(item) === undefined ? [] : [jsonObjectValue(item) ?? {}]);
3794
+ }
3795
+ function jsonArrayValue(value) {
3796
+ return Array.isArray(value) ? value : undefined;
3797
+ }
3798
+ function jsonObjectValue(value) {
3799
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
3800
+ return value;
3801
+ }
3802
+ return undefined;
3803
+ }
3804
+ function numberValue(value) {
3805
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
3806
+ }
3807
+ function cloneJsonObject(value) {
3808
+ const copy = {};
3809
+ for (const [key, child] of Object.entries(value)) {
3810
+ copy[key] = cloneJsonValue(child);
3811
+ }
3812
+ return copy;
3813
+ }
3814
+ function cloneJsonValue(value) {
3815
+ if (Array.isArray(value)) {
3816
+ return value.map((item) => cloneJsonValue(item));
3817
+ }
3818
+ if (typeof value === "object" && value !== null) {
3819
+ return cloneJsonObject(value);
3820
+ }
3821
+ return value;
3822
+ }
3823
+ function isUnknownRecord(value) {
3824
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3825
+ }
3826
+ function encodeGlb(document, binary) {
3827
+ const encoder = new TextEncoder();
3828
+ const jsonBytes = encoder.encode(JSON.stringify(document));
3829
+ const paddedJsonLength = align4(jsonBytes.byteLength);
3830
+ const paddedBinaryLength = align4(binary.byteLength);
3831
+ const bytes = new Uint8Array(12 + 8 + paddedJsonLength + 8 + paddedBinaryLength);
3832
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3833
+ view.setUint32(0, 0x46546c67, true);
3834
+ view.setUint32(4, 2, true);
3835
+ view.setUint32(8, bytes.byteLength, true);
3836
+ view.setUint32(12, paddedJsonLength, true);
3837
+ view.setUint32(16, 0x4e4f534a, true);
3838
+ bytes.set(jsonBytes, 20);
3839
+ bytes.fill(0x20, 20 + jsonBytes.byteLength, 20 + paddedJsonLength);
3840
+ const binaryHeaderOffset = 20 + paddedJsonLength;
3841
+ view.setUint32(binaryHeaderOffset, paddedBinaryLength, true);
3842
+ view.setUint32(binaryHeaderOffset + 4, 0x004e4942, true);
3843
+ bytes.set(binary, binaryHeaderOffset + 8);
3844
+ return bytes;
3845
+ }
3846
+ function align4(value) {
3847
+ return Math.ceil(value / 4) * 4;
3848
+ }
3849
+ function point3Array(point) {
3850
+ return [
3851
+ roundGltfNumber(point.x),
3852
+ roundGltfNumber(point.y),
3853
+ roundGltfNumber(point.z),
3854
+ ];
3855
+ }
3856
+ function quaternionFromEulerDeg(rotation) {
3857
+ const x = radians(rotation.x) / 2;
3858
+ const y = radians(rotation.y) / 2;
3859
+ const z = radians(rotation.z) / 2;
3860
+ const sx = Math.sin(x);
3861
+ const cx = Math.cos(x);
3862
+ const sy = Math.sin(y);
3863
+ const cy = Math.cos(y);
3864
+ const sz = Math.sin(z);
3865
+ const cz = Math.cos(z);
3866
+ return [
3867
+ roundGltfNumber(sx * cy * cz + cx * sy * sz),
3868
+ roundGltfNumber(cx * sy * cz - sx * cy * sz),
3869
+ roundGltfNumber(cx * cy * sz + sx * sy * cz),
3870
+ roundGltfNumber(cx * cy * cz - sx * sy * sz),
3871
+ ];
3872
+ }
3873
+ function radians(degrees) {
3874
+ return (degrees * Math.PI) / 180;
3875
+ }
3876
+ function svgAttributes(attributes) {
3877
+ return attributes
3878
+ .flatMap(([name, value]) => value === undefined
3879
+ ? []
3880
+ : [`${name}="${escapeAttribute(String(value))}"`])
3881
+ .join(" ");
3882
+ }
3883
+ function escapeText(value) {
3884
+ return value
3885
+ .replaceAll("&", "&amp;")
3886
+ .replaceAll("<", "&lt;")
3887
+ .replaceAll(">", "&gt;");
3888
+ }
3889
+ function escapeAttribute(value) {
3890
+ return escapeText(value).replaceAll('"', "&quot;");
3891
+ }
3892
+ function svgDataUri(svg) {
3893
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
3894
+ }
3895
+ function colorizedSvg(svg, color) {
3896
+ return color === undefined ? svg : svg.replaceAll("currentColor", color);
3897
+ }
3898
+ function svgNumber(value) {
3899
+ const rounded = Math.round(value * 1_000_000) / 1_000_000;
3900
+ if (Object.is(rounded, -0)) {
3901
+ return "0";
3902
+ }
3903
+ return String(rounded);
3904
+ }
3905
+ function roundGltfNumber(value) {
3906
+ const rounded = Math.round(value * 1_000_000) / 1_000_000;
3907
+ if (Object.is(rounded, -0)) {
3908
+ return 0;
3909
+ }
3910
+ return rounded;
3911
+ }
3912
+ function templateHole(hole, enclosure, canvasMm) {
3913
+ const layout = outsideDrillTemplateLayout(enclosure, canvasMm);
3914
+ return {
3915
+ ...hole,
3916
+ templateCenterMm: drillTemplateCenter(hole, layout, enclosure),
3917
+ };
3918
+ }
3919
+ function drillTemplateCenter(hole, layout, enclosure) {
3920
+ return drillTemplateCenterForPlacement(hole.face, hole.centerMm, layout, enclosure);
3921
+ }
3922
+ function drillTemplateCenterForPlacement(face, centerMm, layout, enclosure) {
3923
+ const { widthMm, lengthMm, depthMm } = enclosure.dimensionsMm;
3924
+ if (face === "left") {
3925
+ const panel = layout.panels.left;
3926
+ return {
3927
+ x: panel.x + depthMm / 2,
3928
+ y: panel.y + lengthMm / 2 - centerMm.y,
3929
+ };
3930
+ }
3931
+ if (face === "right") {
3932
+ const panel = layout.panels.right;
3933
+ return {
3934
+ x: panel.x + depthMm / 2,
3935
+ y: panel.y + lengthMm / 2 - centerMm.y,
3936
+ };
3937
+ }
3938
+ if (face === "back") {
3939
+ const panel = layout.panels.back;
3940
+ return {
3941
+ x: panel.x + widthMm / 2 + centerMm.x,
3942
+ y: panel.y + depthMm / 2 - centerMm.y,
3943
+ };
3944
+ }
3945
+ const panel = layout.panels.top;
3946
+ return {
3947
+ x: panel.x + widthMm / 2 + centerMm.x,
3948
+ y: panel.y + lengthMm / 2 - centerMm.y,
3949
+ };
3950
+ }
3951
+ function drillTemplateCenterForDecal(face, centerMm, layout, enclosure) {
3952
+ const { widthMm, lengthMm, depthMm } = enclosure.dimensionsMm;
3953
+ if (face === "left") {
3954
+ const panel = layout.panels.left;
3955
+ return {
3956
+ x: panel.x + depthMm / 2 + centerMm.x,
3957
+ y: panel.y + lengthMm / 2 - centerMm.y,
3958
+ };
3959
+ }
3960
+ if (face === "right") {
3961
+ const panel = layout.panels.right;
3962
+ return {
3963
+ x: panel.x + depthMm / 2 + centerMm.x,
3964
+ y: panel.y + lengthMm / 2 - centerMm.y,
3965
+ };
3966
+ }
3967
+ if (face === "back") {
3968
+ const panel = layout.panels.back;
3969
+ return {
3970
+ x: panel.x + widthMm / 2 + centerMm.x,
3971
+ y: panel.y + depthMm / 2 - centerMm.y,
3972
+ };
3973
+ }
3974
+ if (face === "bottom") {
3975
+ const panel = layout.panels.bottom;
3976
+ return {
3977
+ x: panel.x + widthMm / 2 + centerMm.x,
3978
+ y: panel.y + depthMm / 2 - centerMm.y,
3979
+ };
3980
+ }
3981
+ const panel = layout.panels.top;
3982
+ return {
3983
+ x: panel.x + widthMm / 2 + centerMm.x,
3984
+ y: panel.y + lengthMm / 2 - centerMm.y,
3985
+ };
3986
+ }
3987
+ function unfoldedDrillTemplateSize(enclosure) {
3988
+ const { widthMm, lengthMm, depthMm } = enclosure.dimensionsMm;
3989
+ return {
3990
+ widthMm: widthMm + depthMm * 2,
3991
+ heightMm: lengthMm + depthMm * 2,
3992
+ };
3993
+ }
3994
+ function outsideDrillTemplateLayout(enclosure, canvasMm) {
3995
+ const { widthMm, lengthMm, depthMm } = enclosure.dimensionsMm;
3996
+ const unfolded = unfoldedDrillTemplateSize(enclosure);
3997
+ const x = canvasMm.widthMm / 2 - unfolded.widthMm / 2;
3998
+ const y = canvasMm.heightMm / 2 - unfolded.heightMm / 2;
3999
+ const top = {
4000
+ id: "top",
4001
+ x: x + depthMm,
4002
+ y: y + depthMm,
4003
+ width: widthMm,
4004
+ height: lengthMm,
4005
+ };
4006
+ return {
4007
+ widthMm: unfolded.widthMm,
4008
+ heightMm: unfolded.heightMm,
4009
+ panels: {
4010
+ top,
4011
+ left: {
4012
+ id: "left",
4013
+ x,
4014
+ y: top.y,
4015
+ width: depthMm,
4016
+ height: lengthMm,
4017
+ },
4018
+ right: {
4019
+ id: "right",
4020
+ x: top.x + widthMm,
4021
+ y: top.y,
4022
+ width: depthMm,
4023
+ height: lengthMm,
4024
+ },
4025
+ back: {
4026
+ id: "back",
4027
+ x: top.x,
4028
+ y,
4029
+ width: widthMm,
4030
+ height: depthMm,
4031
+ },
4032
+ bottom: {
4033
+ id: "bottom",
4034
+ x: top.x,
4035
+ y: top.y + lengthMm,
4036
+ width: widthMm,
4037
+ height: depthMm,
4038
+ },
4039
+ },
4040
+ };
4041
+ }
4042
+ function placementGrid(enclosure) {
4043
+ const { widthMm, lengthMm } = enclosure.dimensionsMm;
4044
+ const rowCount = Math.max(1, Math.floor(lengthMm / STOMPBOX_GRID_TARGET_ROW_PITCH_MM));
4045
+ const usableLengthMm = Math.max(0, lengthMm - STOMPBOX_GRID_EDGE_MARGIN_MM * 2);
4046
+ return {
4047
+ edgeMarginMm: STOMPBOX_GRID_EDGE_MARGIN_MM,
4048
+ widthMm,
4049
+ lengthMm,
4050
+ usableWidthMm: Math.max(0, widthMm - STOMPBOX_GRID_EDGE_MARGIN_MM * 2),
4051
+ usableLengthMm,
4052
+ rowCount,
4053
+ rowPitchMm: roundMillimeters(usableLengthMm / rowCount),
4054
+ };
4055
+ }
4056
+ function autoKnobGrid(count, grid, placementStyle, hardwareProfile) {
4057
+ if (count <= 0) {
4058
+ return {
4059
+ placements: [],
4060
+ };
4061
+ }
4062
+ if (placementStyle.knobGrid === "compact-led-row") {
4063
+ return compactLedKnobGrid(count, grid, placementStyle, hardwareProfile);
4064
+ }
4065
+ return largeMergedKnobGrid(count, grid, placementStyle, hardwareProfile);
4066
+ }
4067
+ function compactLedKnobGrid(count, grid, placementStyle, hardwareProfile) {
4068
+ const largeKnobPartId = placementStyle.defaultPartIds.largeKnob;
4069
+ const smallKnobPartId = placementStyle.defaultPartIds.smallKnob;
4070
+ const largeKnobDiameterMm = defaultPartVisibleDiameterMm(hardwareProfile, placementStyle.defaultPartIds, "largeKnob", STOMPBOX_LARGE_KNOB_DIAMETER_MM);
4071
+ const smallKnobDiameterMm = defaultPartVisibleDiameterMm(hardwareProfile, placementStyle.defaultPartIds, "smallKnob", STOMPBOX_SMALL_KNOB_DIAMETER_MM);
4072
+ const rowOneY = gridRowCenterY(grid, 1);
4073
+ const oneRowKnobY = gridMergedRowCenterY(grid, 1, 2);
4074
+ if (count === 2) {
4075
+ const rowPart = twoColumnKnobChoice(grid, hardwareProfile, placementStyle.defaultPartIds);
4076
+ return {
4077
+ placements: rowKnobPlacements(rowPart.partId, knobColumnCenters(grid, 2, rowPart.diameterMm), oneRowKnobY),
4078
+ };
4079
+ }
4080
+ if (count === 3) {
4081
+ const firstRowPart = twoColumnKnobChoice(grid, hardwareProfile, placementStyle.defaultPartIds);
4082
+ const firstRowXCenters = knobColumnCenters(grid, 2, firstRowPart.diameterMm);
4083
+ return {
4084
+ placements: [
4085
+ ...rowKnobPlacements(firstRowPart.partId, firstRowXCenters, compactLedFirstKnobRowY(grid, firstRowPart.partId, firstRowXCenters, hardwareProfile, placementStyle)),
4086
+ {
4087
+ partId: smallKnobPartId,
4088
+ centerMm: { x: 0, y: gridRowCenterY(grid, 2) },
4089
+ },
4090
+ ],
4091
+ };
4092
+ }
4093
+ if (count === 4) {
4094
+ if (smallKnobColumnLimit(grid, hardwareProfile, placementStyle.defaultPartIds) >= 4) {
4095
+ const rowDiameterMm = smallKnobDiameterMm + 0.1;
4096
+ return {
4097
+ placements: rowKnobPlacements(smallKnobPartId, knobColumnCenters(grid, 4, rowDiameterMm), oneRowKnobY),
4098
+ };
4099
+ }
4100
+ const twoColumnCenters = knobColumnCenters(grid, 2, smallKnobDiameterMm);
4101
+ return {
4102
+ placements: [
4103
+ ...rowKnobPlacements(smallKnobPartId, twoColumnCenters, rowOneY),
4104
+ ...rowKnobPlacements(smallKnobPartId, twoColumnCenters, gridRowCenterY(grid, 2)),
4105
+ ],
4106
+ };
4107
+ }
4108
+ throw new Error(`unsupported compact-led-row stompbox layout for ${count} knobs`);
4109
+ }
4110
+ function compactLedFirstKnobRowY(grid, knobPartId, xCenters, hardwareProfile, placementStyle) {
4111
+ const defaultY = gridRowCenterY(grid, 1);
4112
+ const ledRadiusMm = (partProfileVisibleDiameterMm(hardwareProfile, placementStyle.defaultPartIds.led) ?? 0) / 2;
4113
+ const knobRadiusMm = (partProfileVisibleDiameterMm(hardwareProfile, knobPartId) ?? 0) / 2;
4114
+ const nearestHorizontalMm = Math.min(...xCenters.map((x) => Math.abs(x)));
4115
+ const combinedRadiusMm = ledRadiusMm + knobRadiusMm;
4116
+ const requiredVerticalMm = Math.sqrt(Math.max(0, combinedRadiusMm * combinedRadiusMm -
4117
+ nearestHorizontalMm * nearestHorizontalMm)) + 0.25;
4118
+ return roundMillimeters(Math.min(defaultY, topEdgeLedY(grid, hardwareProfile, placementStyle.defaultPartIds) -
4119
+ requiredVerticalMm));
4120
+ }
4121
+ function largeMergedKnobGrid(count, grid, placementStyle, hardwareProfile) {
4122
+ const largeKnobPartId = placementStyle.defaultPartIds.largeKnob;
4123
+ const smallKnobPartId = placementStyle.defaultPartIds.smallKnob;
4124
+ const largeKnobDiameterMm = defaultPartVisibleDiameterMm(hardwareProfile, placementStyle.defaultPartIds, "largeKnob", STOMPBOX_LARGE_KNOB_DIAMETER_MM);
4125
+ const smallKnobDiameterMm = defaultPartVisibleDiameterMm(hardwareProfile, placementStyle.defaultPartIds, "smallKnob", STOMPBOX_SMALL_KNOB_DIAMETER_MM);
4126
+ const rowOneY = gridRowCenterY(grid, 1);
4127
+ const rowTwoY = gridRowCenterY(grid, 2);
4128
+ const upperMergedRowY = gridMergedRowCenterY(grid, 1, 2);
4129
+ if (count === 1) {
4130
+ return {
4131
+ placements: [
4132
+ { partId: largeKnobPartId, centerMm: { x: 0, y: upperMergedRowY } },
4133
+ ],
4134
+ };
4135
+ }
4136
+ if (count === 2) {
4137
+ const rowPart = twoColumnKnobChoice(grid, hardwareProfile, placementStyle.defaultPartIds);
4138
+ return {
4139
+ placements: rowKnobPlacements(rowPart.partId, knobColumnCenters(grid, 2, rowPart.diameterMm), upperMergedRowY),
4140
+ };
4141
+ }
4142
+ if (count === 3) {
4143
+ return {
4144
+ placements: [
4145
+ { partId: smallKnobPartId, centerMm: { x: 0, y: rowOneY } },
4146
+ ...rowKnobPlacements(smallKnobPartId, knobColumnCenters(grid, 2, smallKnobDiameterMm), rowTwoY),
4147
+ ],
4148
+ };
4149
+ }
4150
+ if (count === 4) {
4151
+ const twoColumnCenters = knobColumnCenters(grid, 2, smallKnobDiameterMm);
4152
+ return {
4153
+ placements: [
4154
+ ...rowKnobPlacements(smallKnobPartId, twoColumnCenters, rowOneY),
4155
+ ...rowKnobPlacements(smallKnobPartId, twoColumnCenters, rowTwoY),
4156
+ ],
4157
+ };
4158
+ }
4159
+ if (count === 5) {
4160
+ return {
4161
+ placements: [
4162
+ ...rowKnobPlacements(smallKnobPartId, knobColumnCenters(grid, 2, smallKnobDiameterMm), rowOneY),
4163
+ ...rowKnobPlacements(smallKnobPartId, knobColumnCenters(grid, 3, smallKnobDiameterMm), rowTwoY),
4164
+ ],
4165
+ };
4166
+ }
4167
+ if (count === 6) {
4168
+ const threeColumnCenters = knobColumnCenters(grid, 3, smallKnobDiameterMm);
4169
+ return {
4170
+ placements: [
4171
+ ...rowKnobPlacements(smallKnobPartId, threeColumnCenters, rowOneY),
4172
+ ...rowKnobPlacements(smallKnobPartId, threeColumnCenters, rowTwoY),
4173
+ ],
4174
+ };
4175
+ }
4176
+ const columns = Math.max(1, Math.min(smallKnobColumnLimit(grid, hardwareProfile, placementStyle.defaultPartIds), count));
4177
+ const columnCenters = knobColumnCenters(grid, columns, smallKnobDiameterMm);
4178
+ return {
4179
+ placements: Array.from({ length: count }, (_unused, index) => {
4180
+ const x = columnCenters[index % columns] ?? 0;
4181
+ const row = Math.floor(index / columns);
4182
+ return {
4183
+ partId: smallKnobPartId,
4184
+ centerMm: {
4185
+ x,
4186
+ y: gridRowCenterY(grid, row + 1),
4187
+ },
4188
+ };
4189
+ }),
4190
+ };
4191
+ }
4192
+ function rowKnobPlacements(partId, xCenters, y) {
4193
+ return xCenters.map((x) => ({
4194
+ partId,
4195
+ centerMm: { x, y },
4196
+ }));
4197
+ }
4198
+ function twoColumnKnobChoice(grid, hardwareProfile, defaultPartIds) {
4199
+ const choices = [
4200
+ {
4201
+ partId: defaultPartIds.largeKnob,
4202
+ diameterMm: defaultPartVisibleDiameterMm(hardwareProfile, defaultPartIds, "largeKnob", STOMPBOX_LARGE_KNOB_DIAMETER_MM),
4203
+ },
4204
+ {
4205
+ partId: defaultPartIds.knob,
4206
+ diameterMm: defaultPartVisibleDiameterMm(hardwareProfile, defaultPartIds, "knob", STOMPBOX_SMALL_KNOB_DIAMETER_MM),
4207
+ },
4208
+ {
4209
+ partId: defaultPartIds.smallKnob,
4210
+ diameterMm: defaultPartVisibleDiameterMm(hardwareProfile, defaultPartIds, "smallKnob", STOMPBOX_SMALL_KNOB_DIAMETER_MM),
4211
+ },
4212
+ ];
4213
+ const fallback = choices[2];
4214
+ return (choices.find((choice) => knobColumnLimitForDiameter(grid, choice.diameterMm) >= 2) ?? fallback);
4215
+ }
4216
+ function lowerTopLedY(knobCount, grid) {
4217
+ if ((knobCount === 1 || knobCount === 2) && grid.rowCount >= 3) {
4218
+ return gridRowLowerHalfCenterY(grid, 3);
4219
+ }
4220
+ return gridRowCenterY(grid, Math.min(3, grid.rowCount));
4221
+ }
4222
+ function topEdgeLedY(grid, hardwareProfile, defaultPartIds) {
4223
+ const ledRadiusMm = (partProfileVisibleDiameterMm(hardwareProfile, defaultPartIds.led) ??
4224
+ 3.48) / 2;
4225
+ return roundMillimeters(gridTopInsetY(grid) - ledRadiusMm);
4226
+ }
4227
+ function footswitchGridY(knobCount, grid, placementStyle) {
4228
+ if (placementStyle.footswitch === "lower-row" &&
4229
+ (knobCount === 1 || knobCount === 2) &&
4230
+ grid.rowCount >= 4) {
4231
+ return gridRowCenterY(grid, 4);
4232
+ }
4233
+ if (placementStyle.footswitch === "bottom-merged-row" && grid.rowCount >= 5) {
4234
+ return gridMergedRowCenterY(grid, 4, 5);
4235
+ }
4236
+ return gridRowCenterY(grid, grid.rowCount);
4237
+ }
4238
+ function gridRowCenterY(grid, rowIndex) {
4239
+ return roundMillimeters(grid.lengthMm / 2 - grid.edgeMarginMm - (rowIndex - 0.5) * grid.rowPitchMm);
4240
+ }
4241
+ function gridMergedRowCenterY(grid, firstRowIndex, lastRowIndex) {
4242
+ const rowSpan = lastRowIndex - firstRowIndex + 1;
4243
+ return roundMillimeters(grid.lengthMm / 2 -
4244
+ grid.edgeMarginMm -
4245
+ (firstRowIndex - 1) * grid.rowPitchMm -
4246
+ (rowSpan * grid.rowPitchMm) / 2);
4247
+ }
4248
+ function gridRowLowerHalfCenterY(grid, rowIndex) {
4249
+ return roundMillimeters(gridRowCenterY(grid, rowIndex) - grid.rowPitchMm / 4);
4250
+ }
4251
+ function gridTopInsetY(grid) {
4252
+ return roundMillimeters(grid.lengthMm / 2 - grid.edgeMarginMm);
4253
+ }
4254
+ function knobColumnCenters(grid, columns, diameterMm) {
4255
+ if (columns <= 1) {
4256
+ return [0];
4257
+ }
4258
+ const columnWidth = grid.usableWidthMm / columns;
4259
+ const left = -grid.widthMm / 2 + grid.edgeMarginMm + columnWidth / 2;
4260
+ const cellCenters = Array.from({ length: columns }, (_unused, index) => roundMillimeters(left + index * columnWidth));
4261
+ const first = cellCenters[0] ?? 0;
4262
+ const last = cellCenters[cellCenters.length - 1] ?? 0;
4263
+ const requestedSpan = (columns - 1) * diameterMm;
4264
+ const cellSpan = last - first;
4265
+ if (requestedSpan > cellSpan + 0.001) {
4266
+ const expandedLeft = -requestedSpan / 2;
4267
+ return Array.from({ length: columns }, (_unused, index) => roundMillimeters(expandedLeft + index * diameterMm));
4268
+ }
4269
+ return cellCenters;
4270
+ }
4271
+ function largeKnobColumnLimit(grid, hardwareProfile, defaultPartIds) {
4272
+ const largeKnobDiameterMm = defaultPartVisibleDiameterMm(hardwareProfile, defaultPartIds, "largeKnob", STOMPBOX_LARGE_KNOB_DIAMETER_MM);
4273
+ return knobColumnLimitForDiameter(grid, largeKnobDiameterMm);
4274
+ }
4275
+ function knobColumnLimitForDiameter(grid, diameterMm) {
4276
+ return Math.max(1, Math.floor(grid.usableWidthMm /
4277
+ Math.max(STOMPBOX_LARGE_KNOB_MIN_PITCH_MM, diameterMm + 5)));
4278
+ }
4279
+ function smallKnobColumnLimit(grid, hardwareProfile, defaultPartIds) {
4280
+ if (grid.widthMm >= STOMPBOX_1590B_MIN_WIDTH_MM) {
4281
+ return 4;
4282
+ }
4283
+ const smallKnobDiameterMm = defaultPartVisibleDiameterMm(hardwareProfile, defaultPartIds, "smallKnob", STOMPBOX_SMALL_KNOB_DIAMETER_MM);
4284
+ return Math.max(1, Math.floor(grid.usableWidthMm / smallKnobDiameterMm));
4285
+ }
4286
+ function distributedTopRowPositions(count, y, spanMm) {
4287
+ if (count <= 0) {
4288
+ return [];
4289
+ }
4290
+ if (count === 1) {
4291
+ return [{ x: 0, y }];
4292
+ }
4293
+ const spacing = spanMm / (count - 1);
4294
+ const left = -spanMm / 2;
4295
+ return Array.from({ length: count }, (_, index) => ({
4296
+ x: roundMillimeters(left + spacing * index),
4297
+ y,
4298
+ }));
4299
+ }
4300
+ function faceForJack(jack) {
4301
+ if (jack.role === "input") {
4302
+ return "right";
4303
+ }
4304
+ if (jack.role === "output" || jack.role === "direct-output") {
4305
+ return "left";
4306
+ }
4307
+ return undefined;
4308
+ }
4309
+ function hasStatusLed(panel, declared) {
4310
+ return (panel.leds.length > 0 ||
4311
+ declared.some((candidate) => candidate.kind === "led"));
4312
+ }
4313
+ function hasBypassFootswitch(panel, declared) {
4314
+ return (panel.switches.some((switchControl) => isSupportedFootswitch(switchControl)) ||
4315
+ declared.some((candidate) => candidate.kind === "footswitch" || candidate.kind === "switch"));
4316
+ }
4317
+ function hasInputJack(panel, declared) {
4318
+ return (panel.jacks.some((jack) => jack.role === "input") ||
4319
+ declared.some((candidate) => candidate.kind === "jack" && candidate.face === "right"));
4320
+ }
4321
+ function hasOutputJack(panel, declared) {
4322
+ return (panel.jacks.some((jack) => jack.role === "output" || jack.role === "direct-output") ||
4323
+ declared.some((candidate) => candidate.kind === "jack" && candidate.face === "left"));
4324
+ }
4325
+ function hasPowerJack(declared, candidates, defaultPartIds) {
4326
+ return [...declared, ...candidates].some((candidate) => candidate.partId === defaultPartIds.dcJack);
4327
+ }
4328
+ function isSupportedFootswitch(switchControl) {
4329
+ return (switchControl.switchKind === "3pdt" ||
4330
+ switchControl.partNumber?.toLowerCase().includes("3pdt") === true);
4331
+ }
4332
+ function centerForJackFace(face, enclosure, grid, placementStyle, faceIndex = 0) {
4333
+ const y = placementStyle.sideHardware === "back-power-paired-side-jacks"
4334
+ ? pairedSideJackY(grid, faceIndex)
4335
+ : fiveSlotSideAudioJackY(grid, faceIndex);
4336
+ if (face === "right") {
4337
+ return { x: enclosure.dimensionsMm.widthMm / 2, y };
4338
+ }
4339
+ if (face === "left") {
4340
+ return { x: -enclosure.dimensionsMm.widthMm / 2, y };
4341
+ }
4342
+ if (face === "back") {
4343
+ return { x: 0, y: 0 };
4344
+ }
4345
+ return { x: 0, y: gridTopInsetY(grid) };
4346
+ }
4347
+ function powerJackFace(placementStyle) {
4348
+ return placementStyle.sideHardware === "side-power-five-slot"
4349
+ ? "right"
4350
+ : "back";
4351
+ }
4352
+ function centerForPowerJackFace(face, enclosure, grid, placementStyle, hardwareProfile) {
4353
+ if (placementStyle.sideHardware === "side-power-five-slot" &&
4354
+ face === "right") {
4355
+ return {
4356
+ x: enclosure.dimensionsMm.widthMm / 2,
4357
+ y: fiveSlotPowerJackY(grid, hardwareProfile, placementStyle.defaultPartIds),
4358
+ };
4359
+ }
4360
+ return centerForJackFace(face, enclosure, grid, placementStyle);
4361
+ }
4362
+ function pairedSideJackY(grid, faceIndex) {
4363
+ const slotInPair = faceIndex % 2;
4364
+ const pairIndex = Math.floor(faceIndex / 2);
4365
+ const row = Math.min(3 + pairIndex, grid.rowCount);
4366
+ const rowCenterY = gridRowCenterY(grid, row);
4367
+ const rowHalfOffsetY = grid.rowPitchMm / 4;
4368
+ return roundMillimeters(rowCenterY + (slotInPair === 0 ? rowHalfOffsetY : -rowHalfOffsetY));
4369
+ }
4370
+ function fiveSlotSideAudioJackY(grid, faceIndex) {
4371
+ return roundMillimeters(fiveSlotCenterY(grid, 3) - (faceIndex * grid.lengthMm) / 5);
4372
+ }
4373
+ function fiveSlotPowerJackY(grid, hardwareProfile, defaultPartIds) {
4374
+ const audioY = fiveSlotSideAudioJackY(grid, 0);
4375
+ const requestedCloseY = fiveSlotCenterY(grid, 4) + grid.lengthMm / 10;
4376
+ const minimumDistanceY = ((partProfileVisibleDiameterMm(hardwareProfile, defaultPartIds.dcJack) ??
4377
+ 14.1) +
4378
+ (partProfileVisibleDiameterMm(hardwareProfile, defaultPartIds.audioJack) ?? 11)) /
4379
+ 2;
4380
+ return roundMillimeters(Math.min(requestedCloseY, audioY - minimumDistanceY));
4381
+ }
4382
+ function fiveSlotCenterY(grid, slotIndex) {
4383
+ return roundMillimeters(grid.lengthMm / 2 - ((slotIndex - 0.5) * grid.lengthMm) / 5);
4384
+ }
4385
+ function controlIdForPanelElement(element) {
4386
+ return (element.bind.controlId ??
4387
+ element.interfaceControlId ??
4388
+ element.bind.componentId);
4389
+ }
4390
+ function pointFromCorePoint(point) {
4391
+ return { x: point.x, y: point.y };
4392
+ }
4393
+ function defaultPartIdForPanelKind(kind, metadata, defaultPartIds) {
4394
+ switch (kind) {
4395
+ case "knob":
4396
+ case "selector":
4397
+ return defaultPartIds.knob;
4398
+ case "led":
4399
+ return defaultPartIds.led;
4400
+ case "switch":
4401
+ case "footswitch":
4402
+ return metadata?.switchKind === undefined ||
4403
+ metadata.switchKind === "3pdt"
4404
+ ? defaultPartIds.footswitch
4405
+ : undefined;
4406
+ case "jack":
4407
+ return defaultPartIds.audioJack;
4408
+ case "slider":
4409
+ return undefined;
4410
+ }
4411
+ }
4412
+ function knownPartIdOrDefault(requestedPartId, kind, metadata, hardwareProfile, defaultPartIds, diagnostics, controlId, placementId) {
4413
+ if (requestedPartId !== undefined &&
4414
+ hardwareProfile.partProfiles[requestedPartId] !== undefined) {
4415
+ return requestedPartId;
4416
+ }
4417
+ if (requestedPartId !== undefined) {
4418
+ diagnostics.push({
4419
+ code: "unknown-part-profile",
4420
+ message: `Unknown stompbox part profile "${requestedPartId}"`,
4421
+ controlId,
4422
+ ...(placementId === undefined ? {} : { placementId }),
4423
+ });
4424
+ }
4425
+ return defaultPartIdForPanelKind(kind, metadata, defaultPartIds);
4426
+ }
4427
+ function placementIdForKind(kind, controlId) {
4428
+ if (kind === "footswitch") {
4429
+ return `switch-${controlId}`;
4430
+ }
4431
+ return `${kind}-${controlId}`;
4432
+ }
4433
+ function assetResolveOptions(options) {
4434
+ return {
4435
+ ...(options.basePath === undefined ? {} : { basePath: options.basePath }),
4436
+ ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
4437
+ };
4438
+ }
4439
+ function joinAssetBase(base, relativePath) {
4440
+ const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
4441
+ const normalizedPath = relativePath.startsWith("/")
4442
+ ? relativePath.slice(1)
4443
+ : relativePath;
4444
+ return `${normalizedBase}/${normalizedPath}`;
4445
+ }
4446
+ function clamp01(value) {
4447
+ if (!Number.isFinite(value)) {
4448
+ return 0;
4449
+ }
4450
+ return Math.max(0, Math.min(1, value));
4451
+ }
4452
+ function clamp(value, min, max) {
4453
+ if (min > max) {
4454
+ return (min + max) / 2;
4455
+ }
4456
+ return Math.max(min, Math.min(max, value));
4457
+ }
4458
+ function roundMillimeters(value) {
4459
+ const rounded = Math.round(value * 1000) / 1000;
4460
+ return Object.is(rounded, -0) ? 0 : rounded;
4461
+ }
4462
+ //# sourceMappingURL=index.js.map