altium-toolkit 1.1.3 → 1.1.22

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 (39) hide show
  1. package/docs/api.md +37 -0
  2. package/docs/model-format.md +18 -0
  3. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
  4. package/docs/testing.md +5 -0
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +5 -0
  7. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  8. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  9. package/src/core/altium/AltiumParser.mjs +5 -2
  10. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  11. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  12. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  13. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  14. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  15. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  16. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  17. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  18. package/src/core/altium/SchematicPinParser.mjs +87 -20
  19. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  20. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  21. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  22. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  23. package/src/core/altium/SourceComponentClient.mjs +239 -0
  24. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  25. package/src/parser.mjs +8 -0
  26. package/src/styles/altium-renderers.css +6 -6
  27. package/src/ui/PcbArcUtils.mjs +19 -2
  28. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  29. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  30. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  31. package/src/ui/SchematicColorResolver.mjs +185 -0
  32. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  33. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  34. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  35. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  36. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  37. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  38. package/src/ui/SchematicShapeRenderer.mjs +37 -11
  39. package/src/ui/SchematicSvgRenderer.mjs +944 -51
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { ParserUtils } from './ParserUtils.mjs'
6
+ import { SchematicNoErcSymbolResolver } from './SchematicNoErcSymbolResolver.mjs'
6
7
  import { SchematicPinDesignatorInferer } from './SchematicPinDesignatorInferer.mjs'
7
8
 
8
9
  /**
@@ -66,11 +67,9 @@ export class SchematicPinParser {
66
67
  ),
67
68
  designator: ParserUtils.getField(record.fields, 'Designator'),
68
69
  orientation,
69
- electrical:
70
- ParserUtils.parseNumericField(
71
- record.fields,
72
- 'Electrical'
73
- ) || undefined,
70
+ electrical: SchematicPinParser.#parseSchematicPinElectrical(
71
+ record.fields
72
+ ),
74
73
  symbolOuter:
75
74
  ParserUtils.parseNumericField(
76
75
  record.fields,
@@ -94,6 +93,30 @@ export class SchematicPinParser {
94
93
  )
95
94
  }
96
95
 
96
+ /**
97
+ * Parses Altium's pin electrical type, including its omitted-field default.
98
+ * Formal schematic pin records omit Electrical for input pins and serialize
99
+ * passive pins explicitly as Electrical=4.
100
+ * @param {Record<string, string | string[]>} fields Pin record fields.
101
+ * @returns {number | undefined}
102
+ */
103
+ static #parseSchematicPinElectrical(fields) {
104
+ const explicitElectrical = ParserUtils.parseNumericField(
105
+ fields,
106
+ 'Electrical'
107
+ )
108
+
109
+ if (explicitElectrical !== null) {
110
+ return explicitElectrical
111
+ }
112
+
113
+ if (ParserUtils.parseNumericField(fields, 'FormalType') !== null) {
114
+ return 0
115
+ }
116
+
117
+ return undefined
118
+ }
119
+
97
120
  /**
98
121
  * Normalizes schematic port records into drawable port boxes.
99
122
  * @param {{ fields: Record<string, string | string[]> }[]} records
@@ -386,24 +409,36 @@ export class SchematicPinParser {
386
409
  /**
387
410
  * Normalizes no-connect crosses from schematic records.
388
411
  * @param {{ fields: Record<string, string | string[]> }[]} records
389
- * @returns {{ x: number, y: number, size: number, color: string }[]}
412
+ * @returns {{ x: number, y: number, size: number, color: string, symbol: number | null, symbolName: string }[]}
390
413
  */
391
414
  static parseSchematicCrosses(records) {
392
415
  return records
393
- .map((record) => ({
394
- x:
395
- ParserUtils.parseNumericField(
396
- record.fields,
397
- 'Location.X'
398
- ) || 0,
399
- y:
400
- ParserUtils.parseNumericField(
401
- record.fields,
402
- 'Location.Y'
403
- ) || 0,
404
- size: 6,
405
- color: ParserUtils.toColor(record.fields.Color, '#ff0000')
406
- }))
416
+ .map((record) => {
417
+ const rawSymbol = ParserUtils.getField(record.fields, 'Symbol')
418
+ const symbol = ParserUtils.parseNumericField(
419
+ record.fields,
420
+ 'Symbol'
421
+ )
422
+
423
+ return {
424
+ x:
425
+ ParserUtils.parseNumericField(
426
+ record.fields,
427
+ 'Location.X'
428
+ ) || 0,
429
+ y:
430
+ ParserUtils.parseNumericField(
431
+ record.fields,
432
+ 'Location.Y'
433
+ ) || 0,
434
+ size: 6,
435
+ color: ParserUtils.toColor(record.fields.Color, '#ff0000'),
436
+ symbol,
437
+ symbolName: SchematicNoErcSymbolResolver.resolveSymbolName(
438
+ rawSymbol || symbol
439
+ )
440
+ }
441
+ })
407
442
  .filter((cross) => cross.x || cross.y)
408
443
  }
409
444
 
@@ -816,6 +851,13 @@ export class SchematicPinParser {
816
851
  SchematicPinParser.#isTwoPinNumericEndpointGroup(normalizedPins)
817
852
  ) {
818
853
  labelMode = 'number-only'
854
+ } else if (
855
+ SchematicPinParser.#isCompactSinglePinMarkerGroup(
856
+ normalizedPins,
857
+ names
858
+ )
859
+ ) {
860
+ labelMode = 'hidden'
819
861
  } else if (allPassive && normalizedPins.length <= 2) {
820
862
  labelMode = SchematicPinParser.#isCanonicalPassiveTwoPinGroup(
821
863
  normalizedPins
@@ -875,6 +917,31 @@ export class SchematicPinParser {
875
917
  }))
876
918
  }
877
919
 
920
+ /**
921
+ * Returns true when a single owner pin belongs to compact marker artwork
922
+ * rather than to a visibly numbered electrical contact.
923
+ * @param {{ designator: string, name: string, length: number, electrical?: number }[]} pins
924
+ * @param {string[]} names
925
+ * @returns {boolean}
926
+ */
927
+ static #isCompactSinglePinMarkerGroup(pins, names) {
928
+ if (pins.length !== 1 || names.length !== 0) {
929
+ return false
930
+ }
931
+
932
+ const pin = pins[0]
933
+ const designator = String(pin.designator || '').trim()
934
+ const length = Math.abs(Number(pin.length || 0))
935
+ const electrical = Number(pin.electrical)
936
+
937
+ return (
938
+ /^\d+$/.test(designator) &&
939
+ length > 0 &&
940
+ length <= 15 &&
941
+ (!Number.isFinite(electrical) || electrical === 4)
942
+ )
943
+ }
944
+
878
945
  /**
879
946
  * Returns true when a compact owner-drawn FET body uses semantic terminal
880
947
  * names internally but still exposes external numeric contact labels.
@@ -98,6 +98,18 @@ export class SchematicPrimitiveParser {
98
98
  )
99
99
  }
100
100
 
101
+ /**
102
+ * Returns true when one point-listed record carries only an axis-aligned
103
+ * rectangular point list without authored `Location` and `Corner` fields.
104
+ * @param {Record<string, string | string[]>} fields
105
+ * @returns {boolean}
106
+ */
107
+ static isPointListedRectangleRecord(fields) {
108
+ return Boolean(
109
+ SchematicPrimitiveParser.#pointListedRectangleBounds(fields)
110
+ )
111
+ }
112
+
101
113
  /**
102
114
  * Normalizes record-7 polygon primitives into fill-capable polygons.
103
115
  * @param {{ fields: Record<string, string | string[]> }[]} records
@@ -352,16 +364,32 @@ export class SchematicPrimitiveParser {
352
364
  static parseSchematicRectangles(records) {
353
365
  return records
354
366
  .map((record, index) => {
355
- const x1 = parseNumericField(record.fields, 'Location.X')
356
- const y1 = parseNumericField(record.fields, 'Location.Y')
357
- const x2 = parseNumericField(record.fields, 'Corner.X')
358
- const y2 = parseNumericField(record.fields, 'Corner.Y')
367
+ const pointListedBounds =
368
+ SchematicPrimitiveParser.#pointListedRectangleBounds(
369
+ record.fields
370
+ )
371
+ const x1 =
372
+ parseNumericField(record.fields, 'Location.X') ??
373
+ pointListedBounds?.minX ??
374
+ null
375
+ const y1 =
376
+ parseNumericField(record.fields, 'Location.Y') ??
377
+ pointListedBounds?.minY ??
378
+ null
379
+ const x2 =
380
+ parseNumericField(record.fields, 'Corner.X') ??
381
+ pointListedBounds?.maxX ??
382
+ null
383
+ const y2 =
384
+ parseNumericField(record.fields, 'Corner.Y') ??
385
+ pointListedBounds?.maxY ??
386
+ null
359
387
  const isRectangleRecord =
360
388
  SchematicPrimitiveParser.isRectangleRecord(record.fields)
361
389
  const isListedRectangle =
362
390
  SchematicPrimitiveParser.isListedRectangleRecord(
363
391
  record.fields
364
- )
392
+ ) || Boolean(pointListedBounds)
365
393
  const usesFrameFallback =
366
394
  SchematicPrimitiveParser.#shouldUseFrameFallback(
367
395
  record.fields,
@@ -402,9 +430,15 @@ export class SchematicPrimitiveParser {
402
430
  : parseBoolean(record.fields.Transparent),
403
431
  lineWidth:
404
432
  parseNumericField(record.fields, 'LineWidth') || 1,
405
- lineStyle: usesFrameFallback
406
- ? 1
407
- : parseNumericField(record.fields, 'LineStyle') || 0,
433
+ lineStyle:
434
+ usesFrameFallback ||
435
+ SchematicPrimitiveParser.#shouldDefaultBlanketDash(
436
+ record.fields,
437
+ pointListedBounds
438
+ )
439
+ ? 1
440
+ : parseNumericField(record.fields, 'LineStyle') ||
441
+ 0,
408
442
  renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
409
443
  record.fields,
410
444
  index
@@ -989,6 +1023,80 @@ export class SchematicPrimitiveParser {
989
1023
  )
990
1024
  }
991
1025
 
1026
+ /**
1027
+ * Returns true when a point-listed blanket should use Altium's dashed frame
1028
+ * style even when the printable record omits `LineStyle`.
1029
+ * @param {Record<string, string | string[]>} fields Record fields.
1030
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} pointListedBounds Point-listed bounds.
1031
+ * @returns {boolean}
1032
+ */
1033
+ static #shouldDefaultBlanketDash(fields, pointListedBounds) {
1034
+ return (
1035
+ Boolean(pointListedBounds) &&
1036
+ getField(fields, 'RECORD') === '225' &&
1037
+ getField(fields, 'LineStyle') === ''
1038
+ )
1039
+ }
1040
+
1041
+ /**
1042
+ * Resolves rectangle bounds from a point-listed axis-aligned frame.
1043
+ * @param {Record<string, string | string[]>} fields Record fields.
1044
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
1045
+ */
1046
+ static #pointListedRectangleBounds(fields) {
1047
+ const points = SchematicPrimitiveParser.#normalizeClosedPointList(
1048
+ SchematicPrimitiveParser.#collectPolygonPoints(fields)
1049
+ )
1050
+
1051
+ if (points.length !== 4) {
1052
+ return null
1053
+ }
1054
+
1055
+ const xs = [...new Set(points.map((point) => point.x))]
1056
+ const ys = [...new Set(points.map((point) => point.y))]
1057
+
1058
+ if (xs.length !== 2 || ys.length !== 2) {
1059
+ return null
1060
+ }
1061
+
1062
+ const minX = Math.min(...xs)
1063
+ const maxX = Math.max(...xs)
1064
+ const minY = Math.min(...ys)
1065
+ const maxY = Math.max(...ys)
1066
+ const corners = new Set([
1067
+ minX + ':' + minY,
1068
+ minX + ':' + maxY,
1069
+ maxX + ':' + minY,
1070
+ maxX + ':' + maxY
1071
+ ])
1072
+
1073
+ if (!points.every((point) => corners.has(point.x + ':' + point.y))) {
1074
+ return null
1075
+ }
1076
+
1077
+ return { minX, minY, maxX, maxY }
1078
+ }
1079
+
1080
+ /**
1081
+ * Removes an optional repeated closing point from one source point list.
1082
+ * @param {{ x: number, y: number }[]} points Source points.
1083
+ * @returns {{ x: number, y: number }[]}
1084
+ */
1085
+ static #normalizeClosedPointList(points) {
1086
+ if (points.length < 2) {
1087
+ return points
1088
+ }
1089
+
1090
+ const first = points[0]
1091
+ const last = points.at(-1)
1092
+
1093
+ if (first.x === last.x && first.y === last.y) {
1094
+ return points.slice(0, -1)
1095
+ }
1096
+
1097
+ return points
1098
+ }
1099
+
992
1100
  /**
993
1101
  * Collects one record-7 polygon point list in source order.
994
1102
  * @param {Record<string, string | string[]>} fields
@@ -12,6 +12,8 @@ import { OleConstants } from '../ole/OleConstants.mjs'
12
12
  * containers.
13
13
  */
14
14
  export class SchematicStreamExtractor {
15
+ static #AUXILIARY_PRINTABLE_STREAMS = new Set(['Additional'])
16
+
15
17
  /**
16
18
  * Returns true when one buffer starts with the OLE compound-document
17
19
  * signature.
@@ -38,9 +40,10 @@ export class SchematicStreamExtractor {
38
40
  }
39
41
 
40
42
  /**
41
- * Extracts schematic records from the logical `FileHeader` stream.
43
+ * Extracts schematic records from the logical `FileHeader` stream and
44
+ * Altium's auxiliary printable schematic streams.
42
45
  * @param {ArrayBuffer} arrayBuffer
43
- * @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[] } | null}
46
+ * @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[], embeddedFiles?: object } | null}
44
47
  */
45
48
  static extractFromArrayBuffer(arrayBuffer) {
46
49
  if (!SchematicStreamExtractor.isCompoundDocument(arrayBuffer)) {
@@ -55,20 +58,28 @@ export class SchematicStreamExtractor {
55
58
  return null
56
59
  }
57
60
 
58
- let fileHeaderBytes
61
+ let fileHeaderRecords
59
62
 
60
63
  try {
61
- fileHeaderBytes = compoundDocument.getStream('FileHeader')
64
+ fileHeaderRecords = SchematicStreamExtractor.#parseStreamRecords(
65
+ compoundDocument,
66
+ 'FileHeader'
67
+ )
62
68
  } catch {
63
69
  return null
64
70
  }
65
71
 
66
- const records = AsciiRecordParser.parse(
67
- SchematicStreamExtractor.#toArrayBuffer(fileHeaderBytes)
68
- ).map((record) => ({
69
- ...record,
70
- sourceStream: 'FileHeader'
71
- }))
72
+ const streamNames = compoundDocument.listStreams()
73
+ const auxiliaryStreamNames = streamNames.filter((streamName) =>
74
+ SchematicStreamExtractor.#isAuxiliaryPrintableStream(streamName)
75
+ )
76
+ const auxiliaryRecords = auxiliaryStreamNames.flatMap((streamName) =>
77
+ SchematicStreamExtractor.#parseStreamRecords(
78
+ compoundDocument,
79
+ streamName
80
+ )
81
+ )
82
+ const records = [...fileHeaderRecords, ...auxiliaryRecords]
72
83
 
73
84
  if (!records.length) {
74
85
  return null
@@ -76,18 +87,54 @@ export class SchematicStreamExtractor {
76
87
 
77
88
  return {
78
89
  records,
79
- streamNames: compoundDocument.listStreams(),
90
+ streamNames,
80
91
  embeddedFiles: EmbeddedFileInventoryBuilder.buildFromStreams(
81
92
  new Map(
82
- compoundDocument
83
- .listStreams()
84
- .map((name) => [name, compoundDocument.getStream(name)])
93
+ streamNames.map((name) => [
94
+ name,
95
+ compoundDocument.getStream(name)
96
+ ])
85
97
  ),
86
- { skipStreamNames: ['FileHeader'] }
98
+ {
99
+ skipStreamNames: ['FileHeader', ...auxiliaryStreamNames]
100
+ }
87
101
  )
88
102
  }
89
103
  }
90
104
 
105
+ /**
106
+ * Parses printable records from one compound-document stream.
107
+ * @param {OleCompoundDocument} compoundDocument
108
+ * @param {string} streamName
109
+ * @returns {Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>}
110
+ */
111
+ static #parseStreamRecords(compoundDocument, streamName) {
112
+ return AsciiRecordParser.parse(
113
+ SchematicStreamExtractor.#toArrayBuffer(
114
+ compoundDocument.getStream(streamName)
115
+ )
116
+ ).map((record) => ({
117
+ ...record,
118
+ sourceStream: streamName
119
+ }))
120
+ }
121
+
122
+ /**
123
+ * Returns true for known non-embedded schematic streams with printable
124
+ * primitive records.
125
+ * @param {string} streamName
126
+ * @returns {boolean}
127
+ */
128
+ static #isAuxiliaryPrintableStream(streamName) {
129
+ const leafName = String(streamName || '')
130
+ .split('/')
131
+ .at(-1)
132
+
133
+ return SchematicStreamExtractor.#AUXILIARY_PRINTABLE_STREAMS.has(
134
+ leafName
135
+ )
136
+ }
137
+
91
138
  /**
92
139
  * Returns an ArrayBuffer view over one byte slice.
93
140
  * @param {Uint8Array} bytes
@@ -0,0 +1,156 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SourceComponentBundleNormalizer } from './SourceComponentBundleNormalizer.mjs'
6
+
7
+ /**
8
+ * Exports normalized source component bundles as deterministic file entries.
9
+ */
10
+ export class SourceBundleExporter {
11
+ /**
12
+ * Exports one source bundle.
13
+ * @param {object} componentBundle Component bundle or raw response.
14
+ * @param {{ includeModels?: boolean }} [options] Export options.
15
+ * @returns {{ manifest: object, entries: { path: string, bytes: Uint8Array, contentType: string }[] }}
16
+ */
17
+ static export(componentBundle, options = {}) {
18
+ const bundle =
19
+ SourceComponentBundleNormalizer.normalize(componentBundle)
20
+ const includeModels = options.includeModels !== false
21
+ const manifest = SourceBundleExporter.#buildManifest(
22
+ bundle,
23
+ includeModels
24
+ )
25
+ const entries = [
26
+ SourceBundleExporter.#jsonEntry('manifest.json', manifest),
27
+ SourceBundleExporter.#jsonEntry(
28
+ 'source/source.json',
29
+ bundle.sourceJson
30
+ )
31
+ ]
32
+
33
+ if (includeModels) {
34
+ entries.push(
35
+ ...bundle.models.map((model) => ({
36
+ path:
37
+ 'models/' +
38
+ SourceBundleExporter.#safeFileName(model.name),
39
+ bytes: model.bytes,
40
+ contentType: SourceBundleExporter.#modelContentType(model)
41
+ }))
42
+ )
43
+ }
44
+
45
+ return {
46
+ manifest,
47
+ entries: entries.sort((left, right) =>
48
+ left.path.localeCompare(right.path)
49
+ )
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Builds a deterministic manifest.
55
+ * @param {object} bundle Normalized bundle.
56
+ * @param {boolean} includeModels Whether model assets are included.
57
+ * @returns {object}
58
+ */
59
+ static #buildManifest(bundle, includeModels) {
60
+ return {
61
+ schema: 'ecad-source-bundle-v1',
62
+ component: {
63
+ id: bundle.id,
64
+ name: bundle.name,
65
+ symbolName: bundle.symbol.name,
66
+ footprintName: bundle.footprint.name
67
+ },
68
+ assets: includeModels
69
+ ? bundle.models.map((model) => ({
70
+ name: model.name,
71
+ format: model.format,
72
+ path:
73
+ 'models/' +
74
+ SourceBundleExporter.#safeFileName(model.name),
75
+ byteLength: model.bytes.byteLength
76
+ }))
77
+ : []
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Creates one JSON entry.
83
+ * @param {string} path Entry path.
84
+ * @param {object} value JSON value.
85
+ * @returns {{ path: string, bytes: Uint8Array, contentType: string }}
86
+ */
87
+ static #jsonEntry(path, value) {
88
+ return {
89
+ path,
90
+ bytes: new TextEncoder().encode(
91
+ SourceBundleExporter.#stableStringify(value) + '\n'
92
+ ),
93
+ contentType: 'application/json'
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Returns a stable JSON string with sorted object keys.
99
+ * @param {any} value JSON-compatible value.
100
+ * @returns {string}
101
+ */
102
+ static #stableStringify(value) {
103
+ return JSON.stringify(SourceBundleExporter.#sortJson(value), null, 2)
104
+ }
105
+
106
+ /**
107
+ * Recursively sorts JSON object keys.
108
+ * @param {any} value JSON-compatible value.
109
+ * @returns {any}
110
+ */
111
+ static #sortJson(value) {
112
+ if (Array.isArray(value)) {
113
+ return value.map((entry) => SourceBundleExporter.#sortJson(entry))
114
+ }
115
+
116
+ if (
117
+ value &&
118
+ typeof value === 'object' &&
119
+ !(value instanceof Uint8Array)
120
+ ) {
121
+ return Object.fromEntries(
122
+ Object.keys(value)
123
+ .sort()
124
+ .map((key) => [
125
+ key,
126
+ SourceBundleExporter.#sortJson(value[key])
127
+ ])
128
+ )
129
+ }
130
+
131
+ return value
132
+ }
133
+
134
+ /**
135
+ * Returns a safe bundle file name.
136
+ * @param {string} name Raw file name.
137
+ * @returns {string}
138
+ */
139
+ static #safeFileName(name) {
140
+ return String(name || 'model.step').replace(
141
+ /[\\/:\u0000-\u001f]/gu,
142
+ '_'
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Resolves a model content type.
148
+ * @param {object} model Model descriptor.
149
+ * @returns {string}
150
+ */
151
+ static #modelContentType(model) {
152
+ if (model.format === 'obj') return 'model/obj'
153
+ if (model.format === 'mtl') return 'text/plain'
154
+ return 'model/step'
155
+ }
156
+ }