altium-toolkit 1.1.24 → 1.1.26

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 (35) hide show
  1. package/README.md +5 -3
  2. package/docs/api.md +43 -17
  3. package/docs/model-format.md +51 -11
  4. package/docs/schemas/altium_toolkit/library_compatibility_a1.schema.json +116 -0
  5. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +3 -0
  6. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +58 -1
  7. package/package.json +1 -1
  8. package/spec/library-compatibility.md +65 -0
  9. package/src/core/altium/AltiumParser.mjs +8 -1
  10. package/src/core/altium/LibraryCompatibilityGeometry.mjs +618 -0
  11. package/src/core/altium/LibraryCompatibilityModelHintBuilder.mjs +296 -0
  12. package/src/core/altium/LibraryCompatibilityReportBuilder.mjs +930 -0
  13. package/src/core/altium/LibraryQaReportBuilder.mjs +29 -1
  14. package/src/core/altium/PcbDimensionParser.mjs +1 -0
  15. package/src/core/altium/PcbDimensionReportBuilder.mjs +190 -0
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +140 -10
  17. package/src/core/altium/PcbFabricationReadinessReportBuilder.mjs +607 -0
  18. package/src/core/altium/PcbInspectionReportBuilder.mjs +56 -3
  19. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +138 -3
  20. package/src/core/altium/PcbLibModelParser.mjs +24 -2
  21. package/src/core/altium/PcbLibStorageNameResolver.mjs +171 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +15 -93
  23. package/src/core/altium/PcbPrimitiveFieldOffsetReportBuilder.mjs +352 -0
  24. package/src/core/altium/PcbRuleImpactReportBuilder.mjs +313 -0
  25. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +514 -0
  26. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +442 -2
  27. package/src/core/altium/SchematicImageDiagnosticsBuilder.mjs +214 -0
  28. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +523 -3
  29. package/src/parser.mjs +8 -0
  30. package/src/styles/altium-renderers.css +36 -0
  31. package/src/ui/PcbDimensionPrimitiveRenderer.mjs +422 -0
  32. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +6 -0
  33. package/src/ui/PcbScene3dBuilder.mjs +10 -2
  34. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +225 -0
  35. package/src/ui/PcbSvgRenderer.mjs +78 -2
@@ -0,0 +1,514 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PrintableTextDecoder } from './PrintableTextDecoder.mjs'
6
+
7
+ /**
8
+ * Parses shape-based 3D component-body geometry from native PCB streams.
9
+ */
10
+ export class PcbShapeBasedBodyGeometryParser {
11
+ /**
12
+ * Parses binary shape-based body records from a native stream.
13
+ * @param {Uint8Array | undefined} bytes Stream bytes.
14
+ * @returns {{ fields: Record<string, string | string[]>, staticGeometry?: object }[]}
15
+ */
16
+ static parse(bytes) {
17
+ if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
18
+ return []
19
+ }
20
+
21
+ const records = []
22
+ let offset = 0
23
+
24
+ while (offset + 22 <= bytes.byteLength) {
25
+ const parsed = PcbShapeBasedBodyGeometryParser.#parseRecordAt(
26
+ bytes,
27
+ offset
28
+ )
29
+
30
+ if (parsed) {
31
+ records.push({
32
+ fields: parsed.fields,
33
+ staticGeometry: parsed.staticGeometry
34
+ })
35
+ offset = Math.max(parsed.nextOffset, offset + 1)
36
+ continue
37
+ }
38
+
39
+ offset += 1
40
+ }
41
+
42
+ return records
43
+ }
44
+
45
+ /**
46
+ * Builds a static geometry description from printable body fields.
47
+ * @param {Record<string, string | string[]>} fields Native fields.
48
+ * @param {{ x: number, y: number }[]} [verticesMil] Extracted vertices.
49
+ * @returns {object | undefined}
50
+ */
51
+ static buildStaticGeometry(fields, verticesMil = []) {
52
+ const modelType = PcbShapeBasedBodyGeometryParser.#parseIntegerField(
53
+ fields,
54
+ 'MODEL.MODELTYPE'
55
+ )
56
+
57
+ if (modelType === null) {
58
+ return undefined
59
+ }
60
+
61
+ const standoffHeightMil =
62
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
63
+ fields,
64
+ 'STANDOFFHEIGHT'
65
+ ) || 0
66
+
67
+ switch (modelType) {
68
+ case 0:
69
+ return PcbShapeBasedBodyGeometryParser.#extrudedPolygonGeometry(
70
+ fields,
71
+ verticesMil,
72
+ standoffHeightMil
73
+ )
74
+ case 1:
75
+ return PcbShapeBasedBodyGeometryParser.#roundBodyGeometry(
76
+ fields,
77
+ 'cone',
78
+ standoffHeightMil
79
+ )
80
+ case 2:
81
+ return PcbShapeBasedBodyGeometryParser.#roundBodyGeometry(
82
+ fields,
83
+ 'cylinder',
84
+ standoffHeightMil
85
+ )
86
+ case 3:
87
+ return PcbShapeBasedBodyGeometryParser.#sphereGeometry(
88
+ fields,
89
+ standoffHeightMil
90
+ )
91
+ default:
92
+ return {
93
+ kind: 'unknown-' + modelType,
94
+ status: 'incomplete',
95
+ units: 'mil',
96
+ standoffHeightMil
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Parses one possible shape-based body record at a byte offset.
103
+ * @param {Uint8Array} bytes Stream bytes.
104
+ * @param {number} offset Candidate offset.
105
+ * @returns {{ fields: Record<string, string | string[]>, staticGeometry?: object, nextOffset: number } | null}
106
+ */
107
+ static #parseRecordAt(bytes, offset) {
108
+ const view = new DataView(
109
+ bytes.buffer,
110
+ bytes.byteOffset,
111
+ bytes.byteLength
112
+ )
113
+ const textLength = view.getInt32(offset + 18, true)
114
+
115
+ if (
116
+ textLength <= 0 ||
117
+ textLength > 65536 ||
118
+ offset + 22 + textLength > bytes.byteLength
119
+ ) {
120
+ return null
121
+ }
122
+
123
+ const textBytes = bytes.subarray(offset + 22, offset + 22 + textLength)
124
+ const text = PrintableTextDecoder.decodeBytes(textBytes).replaceAll(
125
+ '\u0000',
126
+ ''
127
+ )
128
+
129
+ if (
130
+ !text.includes('MODEL.') ||
131
+ (!text.includes('MODELID=') && !text.includes('MODEL.NAME='))
132
+ ) {
133
+ return null
134
+ }
135
+
136
+ const fields =
137
+ PcbShapeBasedBodyGeometryParser.#parseFieldRecordBytes(textBytes)
138
+ const vertexBlock = PcbShapeBasedBodyGeometryParser.#parseVertexBlock(
139
+ bytes,
140
+ offset + 22 + textLength
141
+ )
142
+
143
+ return {
144
+ fields,
145
+ staticGeometry: PcbShapeBasedBodyGeometryParser.buildStaticGeometry(
146
+ fields,
147
+ vertexBlock.verticesMil
148
+ ),
149
+ nextOffset:
150
+ vertexBlock.nextOffset > offset
151
+ ? vertexBlock.nextOffset
152
+ : offset + 22 + textLength
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Parses the optional coordinate block after a shape-body field record.
158
+ * @param {Uint8Array} bytes Stream bytes.
159
+ * @param {number} offset Coordinate-count offset.
160
+ * @returns {{ verticesMil: { x: number, y: number }[], nextOffset: number }}
161
+ */
162
+ static #parseVertexBlock(bytes, offset) {
163
+ if (offset + 4 > bytes.byteLength) {
164
+ return {
165
+ verticesMil: [],
166
+ nextOffset: offset
167
+ }
168
+ }
169
+
170
+ const view = new DataView(
171
+ bytes.buffer,
172
+ bytes.byteOffset,
173
+ bytes.byteLength
174
+ )
175
+ const vertexCount = view.getInt32(offset, true)
176
+ const dataOffset = offset + 4
177
+
178
+ if (
179
+ vertexCount < 0 ||
180
+ vertexCount > 10000 ||
181
+ dataOffset + vertexCount * 37 > bytes.byteLength
182
+ ) {
183
+ return {
184
+ verticesMil: [],
185
+ nextOffset: offset
186
+ }
187
+ }
188
+
189
+ const verticesMil = []
190
+
191
+ for (let index = 0; index < vertexCount; index += 1) {
192
+ const vertexOffset = dataOffset + index * 37
193
+ verticesMil.push({
194
+ x: PcbShapeBasedBodyGeometryParser.#roundMil(
195
+ view.getInt32(vertexOffset + 1, true) / 10000
196
+ ),
197
+ y: PcbShapeBasedBodyGeometryParser.#roundMil(
198
+ view.getInt32(vertexOffset + 5, true) / 10000
199
+ )
200
+ })
201
+ }
202
+
203
+ return {
204
+ verticesMil,
205
+ nextOffset: dataOffset + vertexCount * 37
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Parses one printable field record.
211
+ * @param {Uint8Array} bytes Record bytes.
212
+ * @returns {Record<string, string | string[]>}
213
+ */
214
+ static #parseFieldRecordBytes(bytes) {
215
+ const fields = {}
216
+ const text = PrintableTextDecoder.decodeBytes(bytes)
217
+ .replaceAll('\u0000', '')
218
+ .trim()
219
+
220
+ for (const segment of text.split('|')) {
221
+ const trimmedSegment = segment.trim()
222
+ if (!trimmedSegment) {
223
+ continue
224
+ }
225
+
226
+ const separatorIndex = trimmedSegment.indexOf('=')
227
+ if (separatorIndex === -1) {
228
+ continue
229
+ }
230
+
231
+ const key = trimmedSegment.slice(0, separatorIndex).trim()
232
+ const value = trimmedSegment.slice(separatorIndex + 1).trim()
233
+
234
+ if (!key) {
235
+ continue
236
+ }
237
+
238
+ PcbShapeBasedBodyGeometryParser.#appendFieldValue(
239
+ fields,
240
+ key,
241
+ value
242
+ )
243
+ }
244
+
245
+ return fields
246
+ }
247
+
248
+ /**
249
+ * Builds extruded-polygon static geometry.
250
+ * @param {Record<string, string | string[]>} fields Native fields.
251
+ * @param {{ x: number, y: number }[]} verticesMil Extracted vertices.
252
+ * @param {number} standoffHeightMil Standoff height.
253
+ * @returns {object}
254
+ */
255
+ static #extrudedPolygonGeometry(fields, verticesMil, standoffHeightMil) {
256
+ const minZMil =
257
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
258
+ fields,
259
+ 'MODEL.EXTRUDED.MINZ'
260
+ ) || 0
261
+ const maxZMil =
262
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
263
+ fields,
264
+ 'MODEL.EXTRUDED.MAXZ'
265
+ ) ||
266
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
267
+ fields,
268
+ 'OVERALLHEIGHT'
269
+ ) ||
270
+ 0
271
+ const heightMil = PcbShapeBasedBodyGeometryParser.#positiveNumber(
272
+ maxZMil - minZMil
273
+ )
274
+ const hasCompleteGeometry =
275
+ Array.isArray(verticesMil) && verticesMil.length >= 3 && heightMil
276
+
277
+ return PcbShapeBasedBodyGeometryParser.#stripUndefined({
278
+ kind: 'extruded-polygon',
279
+ status: hasCompleteGeometry ? 'complete' : 'incomplete',
280
+ units: 'mil',
281
+ minZMil: PcbShapeBasedBodyGeometryParser.#roundMil(minZMil),
282
+ maxZMil: PcbShapeBasedBodyGeometryParser.#roundMil(maxZMil),
283
+ heightMil,
284
+ standoffHeightMil,
285
+ verticesMil: verticesMil?.length ? verticesMil : undefined
286
+ })
287
+ }
288
+
289
+ /**
290
+ * Builds cone or cylinder static geometry.
291
+ * @param {Record<string, string | string[]>} fields Native fields.
292
+ * @param {'cone' | 'cylinder'} kind Body kind.
293
+ * @param {number} standoffHeightMil Standoff height.
294
+ * @returns {object}
295
+ */
296
+ static #roundBodyGeometry(fields, kind, standoffHeightMil) {
297
+ const upperKind = kind.toUpperCase()
298
+ const radiusMil =
299
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
300
+ fields,
301
+ 'MODEL.' + upperKind + '.RADIUS'
302
+ ) ||
303
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
304
+ fields,
305
+ 'MODEL.CYLINDER.RADIUS'
306
+ )
307
+ const heightMil =
308
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
309
+ fields,
310
+ 'MODEL.' + upperKind + '.HEIGHT'
311
+ ) ||
312
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
313
+ fields,
314
+ 'MODEL.CYLINDER.HEIGHT'
315
+ ) ||
316
+ PcbShapeBasedBodyGeometryParser.#heightFromOverall(fields)
317
+ const complete =
318
+ PcbShapeBasedBodyGeometryParser.#positiveNumber(radiusMil) &&
319
+ PcbShapeBasedBodyGeometryParser.#positiveNumber(heightMil)
320
+
321
+ return PcbShapeBasedBodyGeometryParser.#stripUndefined({
322
+ kind,
323
+ status: complete ? 'complete' : 'incomplete',
324
+ units: 'mil',
325
+ radiusMil:
326
+ PcbShapeBasedBodyGeometryParser.#positiveNumber(radiusMil),
327
+ heightMil:
328
+ PcbShapeBasedBodyGeometryParser.#positiveNumber(heightMil),
329
+ standoffHeightMil
330
+ })
331
+ }
332
+
333
+ /**
334
+ * Builds sphere static geometry.
335
+ * @param {Record<string, string | string[]>} fields Native fields.
336
+ * @param {number} standoffHeightMil Standoff height.
337
+ * @returns {object}
338
+ */
339
+ static #sphereGeometry(fields, standoffHeightMil) {
340
+ const heightFromOverall =
341
+ PcbShapeBasedBodyGeometryParser.#heightFromOverall(fields)
342
+ const radiusMil =
343
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
344
+ fields,
345
+ 'MODEL.SPHERE.RADIUS'
346
+ ) ||
347
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
348
+ fields,
349
+ 'MODEL.CYLINDER.RADIUS'
350
+ ) ||
351
+ (heightFromOverall ? heightFromOverall / 2 : undefined)
352
+ const complete =
353
+ PcbShapeBasedBodyGeometryParser.#positiveNumber(radiusMil)
354
+
355
+ return PcbShapeBasedBodyGeometryParser.#stripUndefined({
356
+ kind: 'sphere',
357
+ status: complete ? 'complete' : 'incomplete',
358
+ units: 'mil',
359
+ radiusMil:
360
+ PcbShapeBasedBodyGeometryParser.#positiveNumber(radiusMil),
361
+ standoffHeightMil
362
+ })
363
+ }
364
+
365
+ /**
366
+ * Derives body height from overall and standoff fields.
367
+ * @param {Record<string, string | string[]>} fields Native fields.
368
+ * @returns {number | undefined}
369
+ */
370
+ static #heightFromOverall(fields) {
371
+ const overallHeightMil =
372
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
373
+ fields,
374
+ 'OVERALLHEIGHT'
375
+ ) || 0
376
+ const standoffHeightMil =
377
+ PcbShapeBasedBodyGeometryParser.#parseMilLikeField(
378
+ fields,
379
+ 'STANDOFFHEIGHT'
380
+ ) || 0
381
+
382
+ return PcbShapeBasedBodyGeometryParser.#positiveNumber(
383
+ overallHeightMil - standoffHeightMil
384
+ )
385
+ }
386
+
387
+ /**
388
+ * Returns the latest meaningful field value from one parsed field map.
389
+ * @param {Record<string, string | string[]>} fields Field map.
390
+ * @param {string} key Field key.
391
+ * @returns {string}
392
+ */
393
+ static #getField(fields, key) {
394
+ const raw = fields[key]
395
+ const values = Array.isArray(raw) ? raw : [raw]
396
+
397
+ return (
398
+ values
399
+ .map((value) => String(value || '').trim())
400
+ .findLast((value) => value.length > 0) || ''
401
+ )
402
+ }
403
+
404
+ /**
405
+ * Appends one field value while preserving duplicate keys.
406
+ * @param {Record<string, string | string[]>} fields Field map.
407
+ * @param {string} key Field key.
408
+ * @param {string} value Field value.
409
+ * @returns {void}
410
+ */
411
+ static #appendFieldValue(fields, key, value) {
412
+ if (!(key in fields)) {
413
+ fields[key] = value
414
+ return
415
+ }
416
+
417
+ const previous = fields[key]
418
+ if (Array.isArray(previous)) {
419
+ previous.push(value)
420
+ return
421
+ }
422
+
423
+ fields[key] = [previous, value]
424
+ }
425
+
426
+ /**
427
+ * Parses one floating-point field.
428
+ * @param {Record<string, string | string[]>} fields Field map.
429
+ * @param {string} key Field key.
430
+ * @returns {number | null}
431
+ */
432
+ static #parseNumberField(fields, key) {
433
+ const raw = PcbShapeBasedBodyGeometryParser.#getField(fields, key)
434
+ const match = raw.match(/-?\d+(?:\.\d+)?(?:E[+-]?\d+)?/i)
435
+
436
+ if (!match) {
437
+ return null
438
+ }
439
+
440
+ const parsed = Number(match[0])
441
+ return Number.isFinite(parsed) ? parsed : null
442
+ }
443
+
444
+ /**
445
+ * Parses one integer-like field.
446
+ * @param {Record<string, string | string[]>} fields Field map.
447
+ * @param {string} key Field key.
448
+ * @returns {number | null}
449
+ */
450
+ static #parseIntegerField(fields, key) {
451
+ const parsed = PcbShapeBasedBodyGeometryParser.#parseNumberField(
452
+ fields,
453
+ key
454
+ )
455
+ if (!Number.isFinite(parsed)) {
456
+ return null
457
+ }
458
+
459
+ return Math.trunc(parsed)
460
+ }
461
+
462
+ /**
463
+ * Parses one mil-like field from text or raw fixed-point storage.
464
+ * @param {Record<string, string | string[]>} fields Field map.
465
+ * @param {string} key Field key.
466
+ * @returns {number | null}
467
+ */
468
+ static #parseMilLikeField(fields, key) {
469
+ const raw = PcbShapeBasedBodyGeometryParser.#getField(fields, key)
470
+ const parsed = PcbShapeBasedBodyGeometryParser.#parseNumberField(
471
+ fields,
472
+ key
473
+ )
474
+
475
+ if (!Number.isFinite(parsed)) {
476
+ return null
477
+ }
478
+
479
+ return /mil/i.test(raw) ? parsed : parsed / 10000
480
+ }
481
+
482
+ /**
483
+ * Returns a rounded positive number or undefined.
484
+ * @param {unknown} value Candidate value.
485
+ * @returns {number | undefined}
486
+ */
487
+ static #positiveNumber(value) {
488
+ const number = Number(value)
489
+ return Number.isFinite(number) && number > 0
490
+ ? PcbShapeBasedBodyGeometryParser.#roundMil(number)
491
+ : undefined
492
+ }
493
+
494
+ /**
495
+ * Rounds one mil value for stable JSON output.
496
+ * @param {number} value Candidate value.
497
+ * @returns {number}
498
+ */
499
+ static #roundMil(value) {
500
+ const rounded = Math.round(Number(value) * 10000) / 10000
501
+ return Object.is(rounded, -0) ? 0 : rounded
502
+ }
503
+
504
+ /**
505
+ * Removes undefined values from a row.
506
+ * @param {object} row Row to clean.
507
+ * @returns {object}
508
+ */
509
+ static #stripUndefined(row) {
510
+ return Object.fromEntries(
511
+ Object.entries(row).filter(([, value]) => value !== undefined)
512
+ )
513
+ }
514
+ }