altium-toolkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +20 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +22 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/GPL-3.0-or-later.txt +232 -0
- package/NOTICE.md +32 -0
- package/README.md +116 -0
- package/docs/api.md +73 -0
- package/docs/model-format.md +36 -0
- package/docs/testing.md +25 -0
- package/examples/README.md +47 -0
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
- package/examples/arduino-uno/SvgViewportController.mjs +306 -0
- package/examples/arduino-uno/example.mjs +480 -0
- package/examples/arduino-uno/index.html +163 -0
- package/examples/arduino-uno/styles.css +552 -0
- package/examples/server.mjs +212 -0
- package/package.json +53 -0
- package/spec/library-scope.md +32 -0
- package/src/core/BinaryReader.mjs +127 -0
- package/src/core/altium/AltiumLayoutParser.mjs +485 -0
- package/src/core/altium/AltiumParser.mjs +1007 -0
- package/src/core/altium/AsciiRecordParser.mjs +151 -0
- package/src/core/altium/ParserUtils.mjs +173 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
- package/src/core/altium/PcbModelParser.mjs +336 -0
- package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
- package/src/core/altium/PcbStreamExtractor.mjs +210 -0
- package/src/core/altium/PrintableTextDecoder.mjs +156 -0
- package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
- package/src/core/altium/SchematicImageParser.mjs +173 -0
- package/src/core/altium/SchematicJunctionParser.mjs +43 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
- package/src/core/altium/SchematicPinParser.mjs +767 -0
- package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
- package/src/core/altium/SchematicSheetParser.mjs +241 -0
- package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
- package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
- package/src/core/altium/SchematicTextParser.mjs +708 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
- package/src/core/ole/OleCompoundDocument.mjs +439 -0
- package/src/core/ole/OleConstants.mjs +64 -0
- package/src/core/ole/OleDirectoryEntry.mjs +95 -0
- package/src/index.mjs +7 -0
- package/src/parser.mjs +21 -0
- package/src/renderers.mjs +15 -0
- package/src/scene3d.mjs +9 -0
- package/src/styles/altium-renderers.css +358 -0
- package/src/ui/BomTableRenderer.mjs +46 -0
- package/src/ui/PcbArcUtils.mjs +189 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
- package/src/ui/PcbScene3dBuilder.mjs +742 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
- package/src/ui/PcbScene3dPackages.mjs +137 -0
- package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
- package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
- package/src/ui/PcbSvgRenderer.mjs +906 -0
- package/src/ui/SchematicColorResolver.mjs +132 -0
- package/src/ui/SchematicContentLayout.mjs +661 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
- package/src/ui/SchematicImageRenderer.mjs +135 -0
- package/src/ui/SchematicJunctionRenderer.mjs +381 -0
- package/src/ui/SchematicNoteRenderer.mjs +427 -0
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
- package/src/ui/SchematicPortRenderer.mjs +558 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
- package/src/ui/SchematicRegionRenderer.mjs +94 -0
- package/src/ui/SchematicShapeRenderer.mjs +398 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
- package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
- package/src/ui/SchematicSvgRenderer.mjs +756 -0
- package/src/ui/SchematicSvgUtils.mjs +182 -0
- package/src/ui/SchematicTypography.mjs +204 -0
- package/src/workers/altium-parser.worker.mjs +29 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
// Static FTP hosting serves raw browser modules, so this parser import must
|
|
6
|
+
// resolve through one vendored browser file instead of a bare package
|
|
7
|
+
// specifier.
|
|
8
|
+
import { unzlibSync } from 'fflate'
|
|
9
|
+
import { PrintableTextDecoder } from './PrintableTextDecoder.mjs'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extracts embedded 3D model payloads and component-body placement metadata
|
|
13
|
+
* from PCB compound-document streams.
|
|
14
|
+
*/
|
|
15
|
+
export class PcbEmbeddedModelExtractor {
|
|
16
|
+
/**
|
|
17
|
+
* Extracts embedded model payloads and component-body placements from one
|
|
18
|
+
* stream map.
|
|
19
|
+
* @param {Map<string, Uint8Array>} streams
|
|
20
|
+
* @returns {{ models: { id: string, checksum: number, name: string, format: string, payloadText: string, sourceStream: string, transform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number } }[], componentBodies: { sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[] }}
|
|
21
|
+
*/
|
|
22
|
+
static extractFromStreams(streams) {
|
|
23
|
+
const modelMetadataRecords =
|
|
24
|
+
PcbEmbeddedModelExtractor.#parseModelMetadataStream(
|
|
25
|
+
streams.get('Models/Data')
|
|
26
|
+
)
|
|
27
|
+
const models = modelMetadataRecords
|
|
28
|
+
.map((record, index) =>
|
|
29
|
+
PcbEmbeddedModelExtractor.#normalizeEmbeddedModel(
|
|
30
|
+
record,
|
|
31
|
+
streams.get('Models/' + index),
|
|
32
|
+
'Models/' + index
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
const componentBodies =
|
|
37
|
+
PcbEmbeddedModelExtractor.#dedupeComponentBodies([
|
|
38
|
+
...PcbEmbeddedModelExtractor.#parseComponentBodyStream(
|
|
39
|
+
streams.get('ComponentBodies6/Data'),
|
|
40
|
+
'ComponentBodies6/Data'
|
|
41
|
+
),
|
|
42
|
+
...PcbEmbeddedModelExtractor.#parseComponentBodyStream(
|
|
43
|
+
streams.get('ShapeBasedComponentBodies6/Data'),
|
|
44
|
+
'ShapeBasedComponentBodies6/Data'
|
|
45
|
+
)
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
models,
|
|
50
|
+
componentBodies
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parses the length-prefixed `Models/Data` metadata stream.
|
|
56
|
+
* @param {Uint8Array | undefined} bytes
|
|
57
|
+
* @returns {Record<string, string | string[]>[]}
|
|
58
|
+
*/
|
|
59
|
+
static #parseModelMetadataStream(bytes) {
|
|
60
|
+
if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const view = new DataView(
|
|
65
|
+
bytes.buffer,
|
|
66
|
+
bytes.byteOffset,
|
|
67
|
+
bytes.byteLength
|
|
68
|
+
)
|
|
69
|
+
const records = []
|
|
70
|
+
let offset = 0
|
|
71
|
+
|
|
72
|
+
while (offset + 4 <= bytes.byteLength) {
|
|
73
|
+
const recordLength = view.getUint32(offset, true)
|
|
74
|
+
offset += 4
|
|
75
|
+
|
|
76
|
+
if (recordLength <= 0 || offset + recordLength > bytes.byteLength) {
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fields = PcbEmbeddedModelExtractor.#parseFieldRecordBytes(
|
|
81
|
+
bytes.subarray(offset, offset + recordLength)
|
|
82
|
+
)
|
|
83
|
+
offset += recordLength
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
PcbEmbeddedModelExtractor.#getField(fields, 'ID') ||
|
|
87
|
+
PcbEmbeddedModelExtractor.#getField(fields, 'NAME')
|
|
88
|
+
) {
|
|
89
|
+
records.push(fields)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return records
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parses one component-body printable stream into model-placement records.
|
|
98
|
+
* @param {Uint8Array | undefined} bytes
|
|
99
|
+
* @param {string} sourceStream
|
|
100
|
+
* @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]}
|
|
101
|
+
*/
|
|
102
|
+
static #parseComponentBodyStream(bytes, sourceStream) {
|
|
103
|
+
if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const arrayBuffer = bytes.buffer.slice(
|
|
108
|
+
bytes.byteOffset,
|
|
109
|
+
bytes.byteOffset + bytes.byteLength
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return PrintableTextDecoder.extractRunBytes(arrayBuffer)
|
|
113
|
+
.map((runBytes) =>
|
|
114
|
+
PcbEmbeddedModelExtractor.#parseFieldRecordBytes(runBytes)
|
|
115
|
+
)
|
|
116
|
+
.map((fields) =>
|
|
117
|
+
PcbEmbeddedModelExtractor.#normalizeComponentBody(
|
|
118
|
+
fields,
|
|
119
|
+
sourceStream
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parses one printable field record without requiring a specific leading
|
|
127
|
+
* marker such as `|RECORD=` or `|KIND=`.
|
|
128
|
+
* @param {Uint8Array} bytes
|
|
129
|
+
* @returns {Record<string, string | string[]>}
|
|
130
|
+
*/
|
|
131
|
+
static #parseFieldRecordBytes(bytes) {
|
|
132
|
+
const fields = {}
|
|
133
|
+
const text = PrintableTextDecoder.decodeBytes(bytes)
|
|
134
|
+
.replaceAll('\u0000', '')
|
|
135
|
+
.trim()
|
|
136
|
+
|
|
137
|
+
for (const segment of text.split('|')) {
|
|
138
|
+
const trimmedSegment = segment.trim()
|
|
139
|
+
if (!trimmedSegment) {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const separatorIndex = trimmedSegment.indexOf('=')
|
|
144
|
+
if (separatorIndex === -1) {
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const key = trimmedSegment.slice(0, separatorIndex).trim()
|
|
149
|
+
const value = trimmedSegment.slice(separatorIndex + 1).trim()
|
|
150
|
+
|
|
151
|
+
if (!key) {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
PcbEmbeddedModelExtractor.#appendFieldValue(fields, key, value)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return fields
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Normalizes one embedded model metadata record and its payload stream.
|
|
163
|
+
* @param {Record<string, string | string[]>} fields
|
|
164
|
+
* @param {Uint8Array | undefined} bytes
|
|
165
|
+
* @param {string} sourceStream
|
|
166
|
+
* @returns {{ id: string, checksum: number, name: string, format: string, payloadText: string, sourceStream: string, transform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number } } | null}
|
|
167
|
+
*/
|
|
168
|
+
static #normalizeEmbeddedModel(fields, bytes, sourceStream) {
|
|
169
|
+
if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const id = PcbEmbeddedModelExtractor.#getField(fields, 'ID')
|
|
174
|
+
const name = PcbEmbeddedModelExtractor.#getField(fields, 'NAME')
|
|
175
|
+
const checksum = PcbEmbeddedModelExtractor.#normalizeChecksum(
|
|
176
|
+
PcbEmbeddedModelExtractor.#parseIntegerField(fields, 'CHECKSUM')
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if (!id || !name || checksum === null) {
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const payloadBytes =
|
|
184
|
+
PcbEmbeddedModelExtractor.#inflateModelPayload(bytes)
|
|
185
|
+
const payloadText = new TextDecoder('utf-8').decode(payloadBytes).trim()
|
|
186
|
+
|
|
187
|
+
if (!payloadText) {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
id,
|
|
193
|
+
checksum,
|
|
194
|
+
name,
|
|
195
|
+
format: PcbEmbeddedModelExtractor.#resolveModelFormat(
|
|
196
|
+
name,
|
|
197
|
+
payloadText
|
|
198
|
+
),
|
|
199
|
+
payloadText,
|
|
200
|
+
sourceStream,
|
|
201
|
+
transform: {
|
|
202
|
+
rotationDeg: {
|
|
203
|
+
x:
|
|
204
|
+
PcbEmbeddedModelExtractor.#parseNumberField(
|
|
205
|
+
fields,
|
|
206
|
+
'ROTX'
|
|
207
|
+
) || 0,
|
|
208
|
+
y:
|
|
209
|
+
PcbEmbeddedModelExtractor.#parseNumberField(
|
|
210
|
+
fields,
|
|
211
|
+
'ROTY'
|
|
212
|
+
) || 0,
|
|
213
|
+
z:
|
|
214
|
+
PcbEmbeddedModelExtractor.#parseNumberField(
|
|
215
|
+
fields,
|
|
216
|
+
'ROTZ'
|
|
217
|
+
) || 0
|
|
218
|
+
},
|
|
219
|
+
dzMil:
|
|
220
|
+
PcbEmbeddedModelExtractor.#parseMilLikeField(
|
|
221
|
+
fields,
|
|
222
|
+
'DZ'
|
|
223
|
+
) || 0
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Normalizes one component-body record into model-placement metadata.
|
|
230
|
+
* @param {Record<string, string | string[]>} fields
|
|
231
|
+
* @param {string} sourceStream
|
|
232
|
+
* @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null } | null}
|
|
233
|
+
*/
|
|
234
|
+
static #normalizeComponentBody(fields, sourceStream) {
|
|
235
|
+
const modelId = PcbEmbeddedModelExtractor.#getField(fields, 'MODELID')
|
|
236
|
+
const name = PcbEmbeddedModelExtractor.#getField(fields, 'MODEL.NAME')
|
|
237
|
+
|
|
238
|
+
if (!modelId && !name) {
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
sourceStream,
|
|
244
|
+
layer: PcbEmbeddedModelExtractor.#getField(fields, 'V7_LAYER'),
|
|
245
|
+
identifier: PcbEmbeddedModelExtractor.#decodeIdentifier(
|
|
246
|
+
PcbEmbeddedModelExtractor.#getField(fields, 'IDENTIFIER')
|
|
247
|
+
),
|
|
248
|
+
modelId,
|
|
249
|
+
checksum: PcbEmbeddedModelExtractor.#normalizeChecksum(
|
|
250
|
+
PcbEmbeddedModelExtractor.#parseIntegerField(
|
|
251
|
+
fields,
|
|
252
|
+
'MODEL.CHECKSUM'
|
|
253
|
+
)
|
|
254
|
+
),
|
|
255
|
+
embedded: /^TRUE$/i.test(
|
|
256
|
+
PcbEmbeddedModelExtractor.#getField(fields, 'MODEL.EMBED')
|
|
257
|
+
),
|
|
258
|
+
name,
|
|
259
|
+
positionMil: {
|
|
260
|
+
x:
|
|
261
|
+
PcbEmbeddedModelExtractor.#parseMilLikeField(
|
|
262
|
+
fields,
|
|
263
|
+
'MODEL.2D.X'
|
|
264
|
+
) || 0,
|
|
265
|
+
y:
|
|
266
|
+
PcbEmbeddedModelExtractor.#parseMilLikeField(
|
|
267
|
+
fields,
|
|
268
|
+
'MODEL.2D.Y'
|
|
269
|
+
) || 0
|
|
270
|
+
},
|
|
271
|
+
rotationDeg:
|
|
272
|
+
PcbEmbeddedModelExtractor.#parseNumberField(
|
|
273
|
+
fields,
|
|
274
|
+
'MODEL.2D.ROTATION'
|
|
275
|
+
) || 0,
|
|
276
|
+
modelRotationDeg: {
|
|
277
|
+
x:
|
|
278
|
+
PcbEmbeddedModelExtractor.#parseNumberField(
|
|
279
|
+
fields,
|
|
280
|
+
'MODEL.3D.ROTX'
|
|
281
|
+
) || 0,
|
|
282
|
+
y:
|
|
283
|
+
PcbEmbeddedModelExtractor.#parseNumberField(
|
|
284
|
+
fields,
|
|
285
|
+
'MODEL.3D.ROTY'
|
|
286
|
+
) || 0,
|
|
287
|
+
z:
|
|
288
|
+
PcbEmbeddedModelExtractor.#parseNumberField(
|
|
289
|
+
fields,
|
|
290
|
+
'MODEL.3D.ROTZ'
|
|
291
|
+
) || 0
|
|
292
|
+
},
|
|
293
|
+
dzMil:
|
|
294
|
+
PcbEmbeddedModelExtractor.#parseMilLikeField(
|
|
295
|
+
fields,
|
|
296
|
+
'MODEL.3D.DZ'
|
|
297
|
+
) || 0,
|
|
298
|
+
overallHeightMil: PcbEmbeddedModelExtractor.#parseMilLikeField(
|
|
299
|
+
fields,
|
|
300
|
+
'OVERALLHEIGHT'
|
|
301
|
+
),
|
|
302
|
+
standoffHeightMil: PcbEmbeddedModelExtractor.#parseMilLikeField(
|
|
303
|
+
fields,
|
|
304
|
+
'STANDOFFHEIGHT'
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Inflates one zlib model payload and falls back to the raw bytes when the
|
|
311
|
+
* stream is already plain text.
|
|
312
|
+
* @param {Uint8Array} bytes
|
|
313
|
+
* @returns {Uint8Array}
|
|
314
|
+
*/
|
|
315
|
+
static #inflateModelPayload(bytes) {
|
|
316
|
+
try {
|
|
317
|
+
return Uint8Array.from(unzlibSync(bytes))
|
|
318
|
+
} catch {
|
|
319
|
+
return bytes
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Deduplicates shape-based body records shared across body streams.
|
|
325
|
+
* @param {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]} componentBodies
|
|
326
|
+
* @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]}
|
|
327
|
+
*/
|
|
328
|
+
static #dedupeComponentBodies(componentBodies) {
|
|
329
|
+
const uniqueBodies = new Map()
|
|
330
|
+
|
|
331
|
+
for (const componentBody of componentBodies) {
|
|
332
|
+
const key = [
|
|
333
|
+
componentBody.modelId,
|
|
334
|
+
componentBody.checksum,
|
|
335
|
+
componentBody.name,
|
|
336
|
+
componentBody.positionMil.x,
|
|
337
|
+
componentBody.positionMil.y,
|
|
338
|
+
componentBody.rotationDeg,
|
|
339
|
+
componentBody.modelRotationDeg.x,
|
|
340
|
+
componentBody.modelRotationDeg.y,
|
|
341
|
+
componentBody.modelRotationDeg.z,
|
|
342
|
+
componentBody.dzMil
|
|
343
|
+
].join('\u0000')
|
|
344
|
+
|
|
345
|
+
if (!uniqueBodies.has(key)) {
|
|
346
|
+
uniqueBodies.set(key, componentBody)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return [...uniqueBodies.values()]
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Resolves one model format from metadata and payload text.
|
|
355
|
+
* @param {string} name
|
|
356
|
+
* @param {string} payloadText
|
|
357
|
+
* @returns {string}
|
|
358
|
+
*/
|
|
359
|
+
static #resolveModelFormat(name, payloadText) {
|
|
360
|
+
const normalizedName = String(name || '').toLowerCase()
|
|
361
|
+
|
|
362
|
+
if (
|
|
363
|
+
normalizedName.endsWith('.step') ||
|
|
364
|
+
normalizedName.endsWith('.stp') ||
|
|
365
|
+
payloadText.startsWith('ISO-10303-21')
|
|
366
|
+
) {
|
|
367
|
+
return 'step'
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (
|
|
371
|
+
normalizedName.endsWith('.wrl') ||
|
|
372
|
+
normalizedName.endsWith('.vrml')
|
|
373
|
+
) {
|
|
374
|
+
return 'wrl'
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return 'unknown'
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Returns the latest meaningful field value from one parsed field map.
|
|
382
|
+
* @param {Record<string, string | string[]>} fields
|
|
383
|
+
* @param {string} key
|
|
384
|
+
* @returns {string}
|
|
385
|
+
*/
|
|
386
|
+
static #getField(fields, key) {
|
|
387
|
+
const raw = fields[key]
|
|
388
|
+
const values = Array.isArray(raw) ? raw : [raw]
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
values
|
|
392
|
+
.map((value) => String(value || '').trim())
|
|
393
|
+
.findLast((value) => value.length > 0) || ''
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Appends one field value while preserving duplicate keys.
|
|
399
|
+
* @param {Record<string, string | string[]>} fields
|
|
400
|
+
* @param {string} key
|
|
401
|
+
* @param {string} value
|
|
402
|
+
* @returns {void}
|
|
403
|
+
*/
|
|
404
|
+
static #appendFieldValue(fields, key, value) {
|
|
405
|
+
if (!(key in fields)) {
|
|
406
|
+
fields[key] = value
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const previous = fields[key]
|
|
411
|
+
if (Array.isArray(previous)) {
|
|
412
|
+
previous.push(value)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
fields[key] = [previous, value]
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Parses one floating-point field.
|
|
421
|
+
* @param {Record<string, string | string[]>} fields
|
|
422
|
+
* @param {string} key
|
|
423
|
+
* @returns {number | null}
|
|
424
|
+
*/
|
|
425
|
+
static #parseNumberField(fields, key) {
|
|
426
|
+
const raw = PcbEmbeddedModelExtractor.#getField(fields, key)
|
|
427
|
+
const match = raw.match(/-?\d+(?:\.\d+)?(?:E[+-]?\d+)?/i)
|
|
428
|
+
|
|
429
|
+
if (!match) {
|
|
430
|
+
return null
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const parsed = Number(match[0])
|
|
434
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Parses one integer-like field.
|
|
439
|
+
* @param {Record<string, string | string[]>} fields
|
|
440
|
+
* @param {string} key
|
|
441
|
+
* @returns {number | null}
|
|
442
|
+
*/
|
|
443
|
+
static #parseIntegerField(fields, key) {
|
|
444
|
+
const parsed = PcbEmbeddedModelExtractor.#parseNumberField(fields, key)
|
|
445
|
+
if (!Number.isFinite(parsed)) {
|
|
446
|
+
return null
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return Math.trunc(parsed)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Parses one mil-like field from text or 1/10000 mil integer storage.
|
|
454
|
+
* @param {Record<string, string | string[]>} fields
|
|
455
|
+
* @param {string} key
|
|
456
|
+
* @returns {number | null}
|
|
457
|
+
*/
|
|
458
|
+
static #parseMilLikeField(fields, key) {
|
|
459
|
+
const raw = PcbEmbeddedModelExtractor.#getField(fields, key)
|
|
460
|
+
const parsed = PcbEmbeddedModelExtractor.#parseNumberField(fields, key)
|
|
461
|
+
|
|
462
|
+
if (!Number.isFinite(parsed)) {
|
|
463
|
+
return null
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return /mil/i.test(raw) ? parsed : parsed / 10000
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Normalizes one signed or unsigned 32-bit checksum to its unsigned form.
|
|
471
|
+
* @param {number | null} checksum
|
|
472
|
+
* @returns {number | null}
|
|
473
|
+
*/
|
|
474
|
+
static #normalizeChecksum(checksum) {
|
|
475
|
+
if (!Number.isInteger(checksum)) {
|
|
476
|
+
return null
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return checksum >>> 0
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Decodes one comma-separated identifier byte list.
|
|
484
|
+
* @param {string} rawIdentifier
|
|
485
|
+
* @returns {string}
|
|
486
|
+
*/
|
|
487
|
+
static #decodeIdentifier(rawIdentifier) {
|
|
488
|
+
const trimmed = String(rawIdentifier || '').trim()
|
|
489
|
+
|
|
490
|
+
if (!trimmed) {
|
|
491
|
+
return ''
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (!/^\d+(?:,\d+)*$/.test(trimmed)) {
|
|
495
|
+
return trimmed
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return String.fromCharCode(
|
|
499
|
+
...trimmed
|
|
500
|
+
.split(',')
|
|
501
|
+
.map((value) => Number.parseInt(value, 10))
|
|
502
|
+
.filter(Number.isInteger)
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
}
|