@vessel-dsp/stompbox 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { defaultControlState, extractPanel, parseCircuitDocumentFile, } from "@vessel-dsp/core";
3
2
  export function getAvailableStompboxStyleProfiles(profiles, filter) {
4
3
  return profiles.filter((profile) => profile.supportedKnobCounts.includes(filter.knobCount));
@@ -171,31 +170,8 @@ export function validateStompboxGlbAsset(bytes, partProfile, options = {}) {
171
170
  diagnostics,
172
171
  };
173
172
  }
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
173
  export function validateStompboxHardwareProfileAssets(hardwareProfile, options = {}) {
174
+ const readAssetFile = requireStompboxAssetFileReader(options);
199
175
  const partIds = options.partIds ?? defaultLiveStatePartProfileIds(hardwareProfile);
200
176
  const assets = {};
201
177
  const diagnostics = [];
@@ -211,7 +187,7 @@ export function validateStompboxHardwareProfileAssets(hardwareProfile, options =
211
187
  continue;
212
188
  }
213
189
  const assetPath = resolveStompboxAssetPaths(partProfile.assets, options).glb;
214
- const validation = validateStompboxGlbAssetFile(assetPath, partProfile);
190
+ const validation = validateStompboxGlbAssetFromPath(assetPath, partProfile, readAssetFile);
215
191
  assets[partId] = validation;
216
192
  diagnostics.push(...validation.diagnostics);
217
193
  }
@@ -222,6 +198,30 @@ export function validateStompboxHardwareProfileAssets(hardwareProfile, options =
222
198
  diagnostics,
223
199
  };
224
200
  }
201
+ export function validateStompboxGlbAssetFromPath(path, partProfile, readAssetFile) {
202
+ try {
203
+ return validateStompboxGlbAsset(readAssetFile(path), partProfile, {
204
+ assetPath: path,
205
+ });
206
+ }
207
+ catch (error) {
208
+ const message = error instanceof Error ? error.message : String(error);
209
+ const diagnostic = {
210
+ code: "invalid-glb-asset",
211
+ message: `Invalid GLB asset for stompbox part "${partProfile.id}": ${message}`,
212
+ partId: partProfile.id,
213
+ assetPath: path,
214
+ };
215
+ return {
216
+ schema: "stompbox-glb-asset-validation/v1",
217
+ partProfileId: partProfile.id,
218
+ assetPath: path,
219
+ valid: false,
220
+ targets: {},
221
+ diagnostics: [diagnostic],
222
+ };
223
+ }
224
+ }
225
225
  export function createStompboxSourcePanelControls(document) {
226
226
  const componentsById = new Map(document.components.map((component) => [component.id, component]));
227
227
  const controls = [];
@@ -232,12 +232,14 @@ export function createStompboxSourcePanelControls(document) {
232
232
  element.kind !== "footswitch") {
233
233
  continue;
234
234
  }
235
+ const sourceControlId = controlIdForPanelElement(element);
235
236
  const sourceComponentId = element.bind.componentId;
236
237
  const component = sourceComponentId === undefined
237
238
  ? undefined
238
239
  : componentsById.get(sourceComponentId);
239
240
  const label = nonEmptyText(element.label) ??
240
241
  nonEmptyText(component?.name) ??
242
+ nonEmptyText(sourceControlId) ??
241
243
  nonEmptyText(sourceComponentId) ??
242
244
  nonEmptyText(element.id) ??
243
245
  "Control";
@@ -246,7 +248,7 @@ export function createStompboxSourcePanelControls(document) {
246
248
  const options = sourcePanelControlOptions(component);
247
249
  const description = sourcePanelControlPropertyText(component, "Description");
248
250
  controls.push({
249
- id: sourceComponentId ??
251
+ id: sourceControlId ??
250
252
  element.id ??
251
253
  `${face.id}-${element.grid.row}-${element.grid.column}`,
252
254
  label,
@@ -266,9 +268,7 @@ export function createStompboxControlSurface(document, options) {
266
268
  const diagnostics = [];
267
269
  const basePanel = extractPanel(document);
268
270
  const panelControls = createStompboxSourcePanelControls(document);
269
- const sourceControls = panelControls.length === 0
270
- ? sourceControlsFromExtractedPanel(basePanel)
271
- : panelControls;
271
+ const sourceControls = mergeSourcePanelControls(panelControls, sourceControlsFromExtractedPanel(basePanel));
272
272
  const unmatchedCompiled = [...(options.compiledControls ?? [])];
273
273
  const descriptors = [];
274
274
  for (const sourceControl of sourceControls) {
@@ -568,7 +568,7 @@ export function createStompboxDrillLayout(document, options = {}) {
568
568
  const panelDeclared = [...declared, ...gridDeclared];
569
569
  const declaredControlIds = new Set(panelDeclared.flatMap((candidate) => candidate.controlId === undefined ? [] : [candidate.controlId]));
570
570
  const auto = autoPlacementCandidates(panel, enclosure, panelDeclared, declaredControlIds, options, hardwareProfile, placementStyle, diagnostics);
571
- const holes = [...panelDeclared, ...auto].flatMap((candidate) => drillHoleForCandidate(candidate, hardwareProfile, diagnostics));
571
+ const holes = collapseConcentricMounts([...panelDeclared, ...auto], hardwareProfile, diagnostics).flatMap((candidate) => drillHoleForCandidate(candidate, hardwareProfile, diagnostics));
572
572
  diagnostics.push(...validateHolePlacements(holes, enclosure, options.minPartClearanceMm));
573
573
  return {
574
574
  schema: "stompbox-drill-layout/v1",
@@ -593,7 +593,27 @@ export function createStompboxPreview(document, options = {}) {
593
593
  const resolveOptions = assetResolveOptions(options);
594
594
  const runtimeState = options.pedalState?.controls ?? options.state;
595
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));
596
+ const parts = drillLayout.holes.flatMap((hole) => {
597
+ const base = previewPartForHole(hole, drillLayout.enclosure, controlMetadata.get(hole.controlId ?? ""), runtimeState, resolveOptions, options.appearance, enabled);
598
+ const dials = hole.concentricDials ?? [];
599
+ if (dials.length === 0) {
600
+ return [base];
601
+ }
602
+ const stacked = dials.map((dial) => {
603
+ const dialPart = previewPartForHole(concentricDialHole(hole, dial), drillLayout.enclosure, controlMetadata.get(dial.controlId ?? ""), runtimeState, resolveOptions, options.appearance, enabled);
604
+ return {
605
+ ...dialPart,
606
+ transform: {
607
+ ...dialPart.transform,
608
+ translationMm: {
609
+ ...dialPart.transform.translationMm,
610
+ z: dialPart.transform.translationMm.z + dial.stackOffsetMm,
611
+ },
612
+ },
613
+ };
614
+ });
615
+ return [base, ...stacked];
616
+ });
597
617
  const decals = [
598
618
  ...normalizeDecals(options.decals, drillLayout.enclosure),
599
619
  ...controlLabelDecals(drillLayout, placementStyle, options.appearance),
@@ -825,6 +845,9 @@ export function createStompboxPreviewGlb(document, options = {}) {
825
845
  ? undefined
826
846
  : validateStompboxHardwareProfileAssets(hardwareProfile, {
827
847
  basePath: options.basePath,
848
+ ...(options.readAssetFile === undefined
849
+ ? {}
850
+ : { readAssetFile: options.readAssetFile }),
828
851
  partIds: liveStatePartProfileIdsForPreview(preview),
829
852
  });
830
853
  const diagnostics = [
@@ -936,6 +959,19 @@ function sourceControlsFromExtractedPanel(panel) {
936
959
  })),
937
960
  ];
938
961
  }
962
+ function mergeSourcePanelControls(panelControls, extractedControls) {
963
+ if (panelControls.length === 0) {
964
+ return extractedControls;
965
+ }
966
+ const seenIds = new Set(panelControls.map((control) => control.id));
967
+ const seenComponents = new Set(panelControls.flatMap((control) => control.sourceComponentId === undefined ? [] : [control.sourceComponentId]));
968
+ return [
969
+ ...panelControls,
970
+ ...extractedControls.filter((control) => !seenIds.has(control.id) &&
971
+ (control.sourceComponentId === undefined ||
972
+ !seenComponents.has(control.sourceComponentId))),
973
+ ];
974
+ }
939
975
  function findMatchingCompiledControlIndex(sourceControl, compiledControls, diagnostics) {
940
976
  if (sourceControl.sourceComponentId !== undefined) {
941
977
  const byComponent = compiledControls.findIndex((control) => control.sourceComponentId === sourceControl.sourceComponentId);
@@ -971,9 +1007,8 @@ function runtimeControlDescriptor(sourceControl, compiledControl) {
971
1007
  const rawValue = compiledControl === undefined
972
1008
  ? (sourceControl?.value ?? min)
973
1009
  : effectiveCompiledControlValue(compiledControl);
974
- const id = sourceControl?.sourceComponentId ??
1010
+ const id = sourceControl?.id ??
975
1011
  compiledControl?.sourceComponentId ??
976
- sourceControl?.id ??
977
1012
  publicRuntimeControlId(compiledControl?.id ?? compiledControl?.name ?? "control");
978
1013
  const label = sourceControl?.label ?? compiledControl?.name ?? id;
979
1014
  const normalizedValue = kind === "switch"
@@ -1412,6 +1447,12 @@ function requireStompboxHardwareProfile(options) {
1412
1447
  }
1413
1448
  return options.hardwareProfile;
1414
1449
  }
1450
+ function requireStompboxAssetFileReader(options) {
1451
+ if (options.readAssetFile === undefined) {
1452
+ throw new Error("stompbox GLB asset file access requires options.readAssetFile or the @vessel-dsp/stompbox/node export");
1453
+ }
1454
+ return options.readAssetFile;
1455
+ }
1415
1456
  function resolveStompboxPlacementStyle(profile, hardwareProfile) {
1416
1457
  return {
1417
1458
  defaultPartIds: {
@@ -1509,8 +1550,8 @@ function declaredPhysicalPlacements(faces, controls, hardwareProfile, defaultPar
1509
1550
  const metadata = controls.get(controlId);
1510
1551
  const requestedPartId = element.physical.partProfileId ??
1511
1552
  defaultPartIdForPanelKind(element.kind, metadata, defaultPartIds);
1512
- const partId = knownPartIdOrDefault(requestedPartId, element.kind, metadata, hardwareProfile, defaultPartIds, diagnostics, controlId, element.id);
1513
- if (partId === undefined) {
1553
+ const partResolution = knownPartIdOrDefault(requestedPartId, element.kind, metadata, hardwareProfile, defaultPartIds, diagnostics, controlId, element.id);
1554
+ if (partResolution === undefined) {
1514
1555
  diagnostics.push({
1515
1556
  code: "unsupported-control",
1516
1557
  message: `Unsupported declared panel element kind "${element.kind}"`,
@@ -1526,7 +1567,10 @@ function declaredPhysicalPlacements(faces, controls, hardwareProfile, defaultPar
1526
1567
  kind: element.kind,
1527
1568
  face: face.id,
1528
1569
  centerMm: pointFromCorePoint(element.physical.centerMm),
1529
- partId,
1570
+ partId: partResolution.partId,
1571
+ ...(partResolution.partProvenance === undefined
1572
+ ? {}
1573
+ : { partProvenance: partResolution.partProvenance }),
1530
1574
  componentId: element.bind.componentId,
1531
1575
  controlId,
1532
1576
  ...(label === undefined ? {} : { label }),
@@ -1536,7 +1580,15 @@ function declaredPhysicalPlacements(faces, controls, hardwareProfile, defaultPar
1536
1580
  ...(element.physical.locked === undefined
1537
1581
  ? {}
1538
1582
  : { locked: element.physical.locked }),
1539
- provenance: "vdsp-declared",
1583
+ ...(element.physical.mountId === undefined
1584
+ ? {}
1585
+ : { mountId: element.physical.mountId }),
1586
+ ...(element.physical.surface === undefined
1587
+ ? {}
1588
+ : { surface: element.physical.surface }),
1589
+ provenance: partResolution.partProvenance === "defaulted"
1590
+ ? "auto-generated"
1591
+ : "vdsp-declared",
1540
1592
  });
1541
1593
  }
1542
1594
  }
@@ -1553,8 +1605,8 @@ function gridPhysicalPlacements(faces, controls, enclosure, hardwareProfile, def
1553
1605
  const metadata = controls.get(controlId);
1554
1606
  const requestedPartId = element.physical?.partProfileId ??
1555
1607
  defaultPartIdForPanelKind(element.kind, metadata, defaultPartIds);
1556
- const partId = knownPartIdOrDefault(requestedPartId, element.kind, metadata, hardwareProfile, defaultPartIds, diagnostics, controlId, element.id);
1557
- if (partId === undefined) {
1608
+ const partResolution = knownPartIdOrDefault(requestedPartId, element.kind, metadata, hardwareProfile, defaultPartIds, diagnostics, controlId, element.id);
1609
+ if (partResolution === undefined) {
1558
1610
  diagnostics.push({
1559
1611
  code: "unsupported-control",
1560
1612
  message: `Unsupported panel grid element kind "${element.kind}"`,
@@ -1570,7 +1622,10 @@ function gridPhysicalPlacements(faces, controls, enclosure, hardwareProfile, def
1570
1622
  kind: element.kind,
1571
1623
  face: face.id,
1572
1624
  centerMm: panelGridCenterMm(face, element, enclosure),
1573
- partId,
1625
+ partId: partResolution.partId,
1626
+ ...(partResolution.partProvenance === undefined
1627
+ ? {}
1628
+ : { partProvenance: partResolution.partProvenance }),
1574
1629
  componentId: element.bind.componentId,
1575
1630
  controlId,
1576
1631
  ...(label === undefined ? {} : { label }),
@@ -1580,6 +1635,12 @@ function gridPhysicalPlacements(faces, controls, enclosure, hardwareProfile, def
1580
1635
  ...(element.physical?.locked === undefined
1581
1636
  ? {}
1582
1637
  : { locked: element.physical.locked }),
1638
+ ...(element.physical?.mountId === undefined
1639
+ ? {}
1640
+ : { mountId: element.physical.mountId }),
1641
+ ...(element.physical?.surface === undefined
1642
+ ? {}
1643
+ : { surface: element.physical.surface }),
1583
1644
  }, diagnostics));
1584
1645
  }
1585
1646
  }
@@ -1752,6 +1813,92 @@ function autoCandidate(candidate, diagnostics) {
1752
1813
  provenance: "auto-generated",
1753
1814
  };
1754
1815
  }
1816
+ /**
1817
+ * Collapses placement candidates that share a `mountId` onto a multi-surface
1818
+ * part into a single base candidate carrying the upper dials as
1819
+ * `concentricDials`. One mount becomes one drill hole with N stacked dials,
1820
+ * ordered by the part profile's `surfaces`. Candidates without a `mountId`, or
1821
+ * whose part declares no `surfaces`, pass through unchanged (one hole each).
1822
+ */
1823
+ function collapseConcentricMounts(candidates, hardwareProfile, diagnostics) {
1824
+ const result = [];
1825
+ const groups = new Map();
1826
+ for (const candidate of candidates) {
1827
+ if (candidate.mountId === undefined) {
1828
+ result.push(candidate);
1829
+ continue;
1830
+ }
1831
+ const members = groups.get(candidate.mountId) ?? [];
1832
+ members.push(candidate);
1833
+ groups.set(candidate.mountId, members);
1834
+ }
1835
+ for (const [mountId, members] of groups) {
1836
+ const part = hardwareProfile.partProfiles[members[0]?.partId ?? ""];
1837
+ const surfaces = part?.surfaces;
1838
+ if (part === undefined || surfaces === undefined || surfaces.length === 0) {
1839
+ // Not a multi-surface part: keep each member as its own hole.
1840
+ result.push(...members);
1841
+ continue;
1842
+ }
1843
+ const memberBySurface = new Map();
1844
+ for (const member of members) {
1845
+ if (member.surface === undefined) {
1846
+ result.push(member);
1847
+ continue;
1848
+ }
1849
+ if (!surfaces.some((surface) => surface.id === member.surface)) {
1850
+ diagnostics.push({
1851
+ code: "unknown-part-surface",
1852
+ message: `Mount "${mountId}" references surface "${member.surface}" not declared by part "${part.id}"`,
1853
+ ...(member.controlId === undefined
1854
+ ? {}
1855
+ : { controlId: member.controlId }),
1856
+ placementId: member.id,
1857
+ face: member.face,
1858
+ });
1859
+ result.push(member);
1860
+ continue;
1861
+ }
1862
+ memberBySurface.set(member.surface, member);
1863
+ }
1864
+ const missing = surfaces.filter((surface) => !memberBySurface.has(surface.id));
1865
+ if (missing.length > 0) {
1866
+ diagnostics.push({
1867
+ code: "concentric-mount-incomplete",
1868
+ message: `Concentric mount "${mountId}" (part "${part.id}") is missing surface(s): ${missing.map((surface) => surface.id).join(", ")}`,
1869
+ placementId: members[0]?.id ?? mountId,
1870
+ face: members[0]?.face ?? "top",
1871
+ });
1872
+ }
1873
+ const ordered = [];
1874
+ for (const surface of surfaces) {
1875
+ const member = memberBySurface.get(surface.id);
1876
+ if (member !== undefined) {
1877
+ ordered.push({ surface, member });
1878
+ }
1879
+ }
1880
+ const base = ordered[0];
1881
+ if (base === undefined) {
1882
+ continue;
1883
+ }
1884
+ const upperDials = ordered
1885
+ .slice(1)
1886
+ .map(({ surface, member }) => ({
1887
+ surface: surface.id,
1888
+ partGeometry: surface.geometry,
1889
+ stackOffsetMm: surface.stackOffsetMm,
1890
+ ...(member.controlId === undefined
1891
+ ? {}
1892
+ : { controlId: member.controlId }),
1893
+ ...(member.componentId === undefined
1894
+ ? {}
1895
+ : { componentId: member.componentId }),
1896
+ ...(member.label === undefined ? {} : { label: member.label }),
1897
+ }));
1898
+ result.push({ ...base.member, concentricDials: upperDials });
1899
+ }
1900
+ return result;
1901
+ }
1755
1902
  function drillHoleForCandidate(candidate, hardwareProfile, diagnostics) {
1756
1903
  const part = hardwareProfile.partProfiles[candidate.partId];
1757
1904
  if (part === undefined) {
@@ -1779,6 +1926,9 @@ function drillHoleForCandidate(candidate, hardwareProfile, diagnostics) {
1779
1926
  partLabel: part.label,
1780
1927
  partFamily: part.family,
1781
1928
  partGeometry: part.geometry,
1929
+ ...(candidate.partProvenance === undefined
1930
+ ? {}
1931
+ : { partProvenance: candidate.partProvenance }),
1782
1932
  ...(part.assetScale === undefined ? {} : { assetScale: part.assetScale }),
1783
1933
  ...(candidate.controlId === undefined
1784
1934
  ? {}
@@ -1793,6 +1943,10 @@ function drillHoleForCandidate(candidate, hardwareProfile, diagnostics) {
1793
1943
  ...(part.stateTargets === undefined
1794
1944
  ? {}
1795
1945
  : { stateTargets: part.stateTargets }),
1946
+ ...(candidate.concentricDials === undefined ||
1947
+ candidate.concentricDials.length === 0
1948
+ ? {}
1949
+ : { concentricDials: candidate.concentricDials }),
1796
1950
  },
1797
1951
  ];
1798
1952
  }
@@ -1897,6 +2051,42 @@ function isOutOfBounds(hole, enclosure) {
1897
2051
  }
1898
2052
  return false;
1899
2053
  }
2054
+ /**
2055
+ * Builds a synthetic drill hole for one upper dial of a concentric mount so it
2056
+ * can be rendered as its own preview part. It reuses the base hole's position
2057
+ * but carries the dial's geometry and control; the caller applies the dial's
2058
+ * `stackOffsetMm` to lift it above the base dial.
2059
+ */
2060
+ function concentricDialHole(hole, dial) {
2061
+ return {
2062
+ id: `${hole.id}-${dial.surface}`,
2063
+ face: hole.face,
2064
+ centerMm: hole.centerMm,
2065
+ drillDiameterMm: hole.drillDiameterMm,
2066
+ ...(hole.drillHoleProfileId === undefined
2067
+ ? {}
2068
+ : { drillHoleProfileId: hole.drillHoleProfileId }),
2069
+ partId: hole.partId,
2070
+ partLabel: hole.partLabel,
2071
+ partFamily: hole.partFamily,
2072
+ partGeometry: dial.partGeometry,
2073
+ ...(hole.partProvenance === undefined
2074
+ ? {}
2075
+ : { partProvenance: hole.partProvenance }),
2076
+ ...(hole.assetScale === undefined ? {} : { assetScale: hole.assetScale }),
2077
+ ...(dial.controlId === undefined ? {} : { controlId: dial.controlId }),
2078
+ ...(dial.componentId === undefined
2079
+ ? {}
2080
+ : { componentId: dial.componentId }),
2081
+ ...(dial.label === undefined ? {} : { label: dial.label }),
2082
+ provenance: hole.provenance,
2083
+ ...(hole.locked === undefined ? {} : { locked: hole.locked }),
2084
+ assets: hole.assets,
2085
+ ...(hole.stateTargets === undefined
2086
+ ? {}
2087
+ : { stateTargets: hole.stateTargets }),
2088
+ };
2089
+ }
1900
2090
  function previewPartForHole(hole, enclosure, metadata, state, assetOptions, appearance, enabled) {
1901
2091
  const rotation = baseRotationForFace(hole.face);
1902
2092
  const stateValue = stateValueForHole(hole, state, enabled);
@@ -1922,6 +2112,9 @@ function previewPartForHole(hole, enclosure, metadata, state, assetOptions, appe
1922
2112
  partId: hole.partId,
1923
2113
  family: hole.partFamily,
1924
2114
  geometry: hole.partGeometry,
2115
+ ...(hole.partProvenance === undefined
2116
+ ? {}
2117
+ : { partProvenance: hole.partProvenance }),
1925
2118
  ...(hole.assetScale === undefined ? {} : { assetScale: hole.assetScale }),
1926
2119
  ...(hole.controlId === undefined ? {} : { controlId: hole.controlId }),
1927
2120
  face: hole.face,
@@ -3022,6 +3215,7 @@ function gltfAssemblySources(preview, options, assetValidation) {
3022
3215
  throw new Error("stompbox GLB assembly requires options.basePath for caller-provided asset files");
3023
3216
  }
3024
3217
  const basePath = options.basePath;
3218
+ const readAssetFile = requireStompboxAssetFileReader(options);
3025
3219
  return [
3026
3220
  {
3027
3221
  id: preview.enclosure.variantId,
@@ -3029,6 +3223,7 @@ function gltfAssemblySources(preview, options, assetValidation) {
3029
3223
  displayGlb: preview.enclosure.assets.glb,
3030
3224
  displayStep: preview.enclosure.assets.step,
3031
3225
  localGlbPath: resolveStompboxAssetPaths(preview.drillLayout.enclosure.assets, { basePath }).glb,
3226
+ readAssetFile,
3032
3227
  ...(preview.enclosure.material === undefined
3033
3228
  ? {}
3034
3229
  : { material: preview.enclosure.material }),
@@ -3051,10 +3246,10 @@ function gltfAssemblySources(preview, options, assetValidation) {
3051
3246
  : { material: previewMaterialJson(preview.enclosure.material) }),
3052
3247
  },
3053
3248
  },
3054
- ...preview.parts.map((part) => partAssemblySource(part, preview.drillLayout, basePath, assetValidation?.assets[part.partId])),
3249
+ ...preview.parts.map((part) => partAssemblySource(part, preview.drillLayout, basePath, readAssetFile, assetValidation?.assets[part.partId])),
3055
3250
  ];
3056
3251
  }
3057
- function partAssemblySource(part, layout, basePath, validation) {
3252
+ function partAssemblySource(part, layout, basePath, readAssetFile, validation) {
3058
3253
  const sourceAssets = sourceAssetRefsForPreviewPart(layout, part);
3059
3254
  const sourceMaterial = part.geometry.kind === "led-bezel" ? undefined : part.material;
3060
3255
  const transform = {
@@ -3072,6 +3267,7 @@ function partAssemblySource(part, layout, basePath, validation) {
3072
3267
  displayGlb: part.assets.glb,
3073
3268
  displayStep: part.assets.step,
3074
3269
  localGlbPath: resolveStompboxAssetPaths(sourceAssets, { basePath }).glb,
3270
+ readAssetFile,
3075
3271
  ...(sourceMaterial === undefined ? {} : { material: sourceMaterial }),
3076
3272
  ...(materialTargets.length === 0 ? {} : { materialTargets }),
3077
3273
  ...(stateTargets === undefined ? {} : { stateTargets }),
@@ -3082,6 +3278,9 @@ function partAssemblySource(part, layout, basePath, validation) {
3082
3278
  partId: part.partId,
3083
3279
  face: part.face,
3084
3280
  provenance: part.provenance,
3281
+ ...(part.partProvenance === undefined
3282
+ ? {}
3283
+ : { partProvenance: part.partProvenance }),
3085
3284
  glb: part.assets.glb,
3086
3285
  step: part.assets.step,
3087
3286
  ...(part.assetScale === undefined ? {} : { assetScale: part.assetScale }),
@@ -3558,7 +3757,7 @@ function appendAssemblySource(state, source) {
3558
3757
  return wrapperIndex;
3559
3758
  }
3560
3759
  function appendSourceGlb(state, source) {
3561
- const parsed = parseGlbFile(source.localGlbPath);
3760
+ const parsed = parseGlbFile(source.localGlbPath, source.readAssetFile);
3562
3761
  const bufferOffset = appendBinaryChunk(state, parsed.binary.slice(0, parsed.bufferByteLength));
3563
3762
  const sourceMaterials = jsonObjectArray(parsed.json, "materials");
3564
3763
  const bufferViewOffset = state.bufferViews.length;
@@ -3715,8 +3914,8 @@ function sourceSceneRootNodeIndexes(json) {
3715
3914
  }
3716
3915
  return nodes.flatMap((node) => (typeof node === "number" ? [node] : []));
3717
3916
  }
3718
- function parseGlbFile(path) {
3719
- const bytes = new Uint8Array(readFileSync(path));
3917
+ function parseGlbFile(path, readAssetFile) {
3918
+ const bytes = readAssetFile(path);
3720
3919
  return parseGlbBytes(bytes, path);
3721
3920
  }
3722
3921
  function parseGlbBytes(bytes, context) {
@@ -4412,7 +4611,7 @@ function defaultPartIdForPanelKind(kind, metadata, defaultPartIds) {
4412
4611
  function knownPartIdOrDefault(requestedPartId, kind, metadata, hardwareProfile, defaultPartIds, diagnostics, controlId, placementId) {
4413
4612
  if (requestedPartId !== undefined &&
4414
4613
  hardwareProfile.partProfiles[requestedPartId] !== undefined) {
4415
- return requestedPartId;
4614
+ return { partId: requestedPartId, partProvenance: "vdsp-declared" };
4416
4615
  }
4417
4616
  if (requestedPartId !== undefined) {
4418
4617
  diagnostics.push({
@@ -4422,7 +4621,15 @@ function knownPartIdOrDefault(requestedPartId, kind, metadata, hardwareProfile,
4422
4621
  ...(placementId === undefined ? {} : { placementId }),
4423
4622
  });
4424
4623
  }
4425
- return defaultPartIdForPanelKind(kind, metadata, defaultPartIds);
4624
+ const defaultPartId = defaultPartIdForPanelKind(kind, metadata, defaultPartIds);
4625
+ return defaultPartId === undefined
4626
+ ? undefined
4627
+ : {
4628
+ partId: defaultPartId,
4629
+ ...(requestedPartId === undefined
4630
+ ? {}
4631
+ : { partProvenance: "defaulted" }),
4632
+ };
4426
4633
  }
4427
4634
  function placementIdForKind(kind, controlId) {
4428
4635
  if (kind === "footswitch") {