altium-toolkit 1.1.33 → 1.1.36

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 (38) hide show
  1. package/AGENTS.md +2 -2
  2. package/docs/model-format.md +2 -3
  3. package/package.json +1 -1
  4. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +255 -1
  5. package/src/core/altium/AltiumPcbLibExporter.mjs +6 -1
  6. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
  7. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +14 -3
  8. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
  9. package/src/core/altium/PrintableTextDecoder.mjs +20 -1
  10. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
  11. package/src/core/altium/SchematicTextParser.mjs +18 -253
  12. package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
  13. package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
  14. package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
  15. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  16. package/src/parser.mjs +1 -0
  17. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
  18. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +6 -2
  19. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
  20. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
  21. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +225 -17
  22. package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
  23. package/src/ui/PcbScene3dBuilder.mjs +470 -20
  24. package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
  25. package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
  26. package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
  27. package/src/ui/PcbScene3dPackages.mjs +31 -4
  28. package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
  29. package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +899 -0
  30. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +471 -35
  31. package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
  32. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +897 -0
  33. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +555 -0
  34. package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +438 -0
  35. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +897 -0
  36. package/src/ui/SchematicColorResolver.mjs +44 -0
  37. package/src/ui/SchematicLineColorResolver.mjs +1 -1
  38. package/src/ui/SchematicShapeRenderer.mjs +13 -13
package/AGENTS.md CHANGED
@@ -33,8 +33,8 @@
33
33
  ## Library Scope
34
34
 
35
35
  - Include parser, OLE/binary helpers, schematic SVG rendering, PCB SVG
36
- rendering, BOM HTML rendering, 3D scene-description builders, and static 3D
37
- summary rendering.
36
+ rendering, BOM HTML rendering, selected-part library export adapters, 3D
37
+ scene-description builders, and static 3D summary rendering.
38
38
  - Do not add host app interaction to this library: no pan/zoom controllers, DOM
39
39
  event orchestration, OrbitControls runtime, picking, download UI, or app state.
40
40
  - Keep parser and renderer fixes universal. Never special-case a specific file
@@ -80,9 +80,8 @@ The legacy renderer compatibility contract is published as a JSON Schema at
80
80
  [`docs/schemas/altium_toolkit/normalized_model_a1.schema.json`](schemas/altium_toolkit/normalized_model_a1.schema.json).
81
81
  Compatibility fields expose the same id through the top-level `schema` field,
82
82
  and consumers can compare it with `NormalizedModelSchema.CURRENT_SCHEMA_ID`.
83
- The serialized parser return value follows the upstream
84
- [`tscircuit/circuit-json`](https://github.com/tscircuit/circuit-json) element
85
- array convention.
83
+ The serialized parser return value follows the Circuit JSON element-array
84
+ convention.
86
85
 
87
86
  ## Parser Utility Reports
88
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.1.33",
3
+ "version": "1.1.36",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -163,7 +163,7 @@ export class AltiumLibraryRecordBuilder {
163
163
 
164
164
  /**
165
165
  * Builds model metadata entries.
166
- * @param {{ model: object, id: string, checksum: number }[]} models Model rows.
166
+ * @param {{ bundle: object, model: object, id: string, checksum: number }[]} models Model rows.
167
167
  * @returns {Uint8Array}
168
168
  */
169
169
  static buildModelsData(models) {
@@ -171,7 +171,12 @@ export class AltiumLibraryRecordBuilder {
171
171
  models.map((row) =>
172
172
  AltiumLibraryRecordBuilder.createLengthPrefixedAscii(
173
173
  AltiumLibraryRecordBuilder.#pipeRecord({
174
+ EMBED: 'TRUE',
175
+ MODELSOURCE: 'Undefined',
174
176
  ID: row.id,
177
+ ...AltiumLibraryRecordBuilder.#modelMetadataTransform(
178
+ row
179
+ ),
175
180
  NAME: row.model.name,
176
181
  CHECKSUM: String(row.checksum),
177
182
  FORMAT: row.model.format
@@ -181,6 +186,23 @@ export class AltiumLibraryRecordBuilder {
181
186
  )
182
187
  }
183
188
 
189
+ /**
190
+ * Builds component-body placement records for embedded model rows.
191
+ * @param {{ bundle: object, model: object, id: string, checksum: number }[]} models Model rows.
192
+ * @returns {Uint8Array}
193
+ */
194
+ static buildComponentBodiesData(models) {
195
+ return AltiumLibraryRecordBuilder.concatBytes(
196
+ models.map((row) =>
197
+ new TextEncoder().encode(
198
+ AltiumLibraryRecordBuilder.#pipeRecord(
199
+ AltiumLibraryRecordBuilder.#componentBodyFields(row)
200
+ ) + '\u0000'
201
+ )
202
+ )
203
+ )
204
+ }
205
+
184
206
  /**
185
207
  * Creates a little-endian count header.
186
208
  * @param {number} count Count value.
@@ -279,6 +301,238 @@ export class AltiumLibraryRecordBuilder {
279
301
  )
280
302
  }
281
303
 
304
+ /**
305
+ * Builds model metadata transform fields.
306
+ * @param {{ bundle: object, model: object }} row Model row.
307
+ * @returns {Record<string, string>}
308
+ */
309
+ static #modelMetadataTransform(row) {
310
+ const rotation = AltiumLibraryRecordBuilder.#modelRotation(
311
+ row.model,
312
+ AltiumLibraryRecordBuilder.#rowComponent(row)
313
+ )
314
+
315
+ return {
316
+ ROTX: AltiumLibraryRecordBuilder.#numberText(rotation.x),
317
+ ROTY: AltiumLibraryRecordBuilder.#numberText(rotation.y),
318
+ ROTZ: AltiumLibraryRecordBuilder.#numberText(rotation.z),
319
+ DZ: AltiumLibraryRecordBuilder.#milText(
320
+ AltiumLibraryRecordBuilder.#modelDzMil(row.model)
321
+ )
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Builds one component-body placement record.
327
+ * @param {{ bundle: object, model: object, id: string, checksum: number }} row Model row.
328
+ * @returns {Record<string, string>}
329
+ */
330
+ static #componentBodyFields(row) {
331
+ const offset = AltiumLibraryRecordBuilder.#modelOffsetMil(row.model)
332
+ const rotation = AltiumLibraryRecordBuilder.#modelRotation(
333
+ row.model,
334
+ AltiumLibraryRecordBuilder.#rowComponent(row)
335
+ )
336
+
337
+ return {
338
+ V7_LAYER: 'MECHANICAL1',
339
+ KIND: '0',
340
+ SUBPOLYINDEX: '-1',
341
+ UNIONINDEX: '0',
342
+ ISSHAPEBASED: 'FALSE',
343
+ STANDOFFHEIGHT: '0mil',
344
+ OVERALLHEIGHT: '0mil',
345
+ IDENTIFIER: AltiumLibraryRecordBuilder.#identifierBytes(
346
+ row.bundle?.footprint?.name || row.model?.name || row.id
347
+ ),
348
+ MODELID: row.id,
349
+ 'MODEL.CHECKSUM': String(row.checksum),
350
+ 'MODEL.EMBED': 'TRUE',
351
+ 'MODEL.NAME': row.model.name,
352
+ 'MODEL.2D.X': AltiumLibraryRecordBuilder.#milText(offset.x),
353
+ 'MODEL.2D.Y': AltiumLibraryRecordBuilder.#milText(offset.y),
354
+ 'MODEL.2D.ROTATION': '0',
355
+ 'MODEL.3D.ROTX': AltiumLibraryRecordBuilder.#numberText(rotation.x),
356
+ 'MODEL.3D.ROTY': AltiumLibraryRecordBuilder.#numberText(rotation.y),
357
+ 'MODEL.3D.ROTZ': AltiumLibraryRecordBuilder.#numberText(rotation.z),
358
+ 'MODEL.3D.DZ': AltiumLibraryRecordBuilder.#milText(
359
+ AltiumLibraryRecordBuilder.#modelDzMil(row.model)
360
+ ),
361
+ 'MODEL.MODELSOURCE': 'Undefined'
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Resolves the source component attached to a model row.
367
+ * @param {{ bundle?: object }} row Model row.
368
+ * @returns {object}
369
+ */
370
+ static #rowComponent(row) {
371
+ return (
372
+ row?.bundle?.footprint?.component ||
373
+ row?.bundle?.footprint?.raw?.component ||
374
+ {}
375
+ )
376
+ }
377
+
378
+ /**
379
+ * Resolves one model transform object.
380
+ * @param {object} model Model asset.
381
+ * @returns {object}
382
+ */
383
+ static #modelTransform(model) {
384
+ return model?.transform || model?.raw?.transform || {}
385
+ }
386
+
387
+ /**
388
+ * Resolves model offset in mils.
389
+ * @param {object} model Model asset.
390
+ * @returns {{ x: number, y: number }}
391
+ */
392
+ static #modelOffsetMil(model) {
393
+ const transform = AltiumLibraryRecordBuilder.#modelTransform(model)
394
+ const offsetMil = transform.offsetMil || {}
395
+
396
+ return {
397
+ x: AltiumLibraryRecordBuilder.#round(
398
+ AltiumLibraryRecordBuilder.#number(
399
+ offsetMil.x ?? transform.dxMil,
400
+ 0
401
+ )
402
+ ),
403
+ y: AltiumLibraryRecordBuilder.#round(
404
+ AltiumLibraryRecordBuilder.#number(
405
+ offsetMil.y ?? transform.dyMil,
406
+ 0
407
+ )
408
+ )
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Resolves one model Z offset in mils.
414
+ * @param {object} model Model asset.
415
+ * @returns {number}
416
+ */
417
+ static #modelDzMil(model) {
418
+ const transform = AltiumLibraryRecordBuilder.#modelTransform(model)
419
+ const offsetMil = transform.offsetMil || {}
420
+
421
+ return AltiumLibraryRecordBuilder.#round(
422
+ AltiumLibraryRecordBuilder.#number(
423
+ offsetMil.z ?? transform.dzMil,
424
+ 0
425
+ )
426
+ )
427
+ }
428
+
429
+ /**
430
+ * Resolves model 3D rotation in footprint-local degrees.
431
+ * @param {object} model Model asset.
432
+ * @param {object} component Selected footprint component origin.
433
+ * @returns {{ x: number, y: number, z: number }}
434
+ */
435
+ static #modelRotation(model, component) {
436
+ const transform = AltiumLibraryRecordBuilder.#modelTransform(model)
437
+ const rotation = transform.rotationDeg || {}
438
+ const generatedRotation =
439
+ AltiumLibraryRecordBuilder.#generatedFrameRotation(model, component)
440
+
441
+ return {
442
+ x: AltiumLibraryRecordBuilder.#round(
443
+ AltiumLibraryRecordBuilder.#number(rotation.x, 0) +
444
+ generatedRotation.x
445
+ ),
446
+ y: AltiumLibraryRecordBuilder.#round(
447
+ AltiumLibraryRecordBuilder.#number(rotation.y, 0) +
448
+ generatedRotation.y
449
+ ),
450
+ z: AltiumLibraryRecordBuilder.#round(
451
+ AltiumLibraryRecordBuilder.#number(rotation.z, 0) +
452
+ generatedRotation.z
453
+ )
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Resolves correction for generated stitched STEP files.
459
+ * @param {object} model Model asset.
460
+ * @param {object} component Selected footprint component origin.
461
+ * @returns {{ x: number, y: number, z: number }}
462
+ */
463
+ static #generatedFrameRotation(model, component) {
464
+ if (model?.generated !== true && model?.raw?.generated !== true) {
465
+ return { x: 0, y: 0, z: 0 }
466
+ }
467
+
468
+ return {
469
+ x: -90,
470
+ y: 0,
471
+ z: AltiumLibraryRecordBuilder.#normalizeAngle(
472
+ -AltiumLibraryRecordBuilder.#number(component?.rotation, 0)
473
+ )
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Encodes one Altium identifier as comma-separated character codes.
479
+ * @param {string} text Source text.
480
+ * @returns {string}
481
+ */
482
+ static #identifierBytes(text) {
483
+ return [...String(text || '')]
484
+ .map((character) => character.charCodeAt(0))
485
+ .join(',')
486
+ }
487
+
488
+ /**
489
+ * Formats a model distance in mils.
490
+ * @param {number} value Value in mils.
491
+ * @returns {string}
492
+ */
493
+ static #milText(value) {
494
+ return AltiumLibraryRecordBuilder.#numberText(value) + 'mil'
495
+ }
496
+
497
+ /**
498
+ * Formats one numeric record field.
499
+ * @param {number} value Numeric value.
500
+ * @returns {string}
501
+ */
502
+ static #numberText(value) {
503
+ return String(AltiumLibraryRecordBuilder.#round(value))
504
+ }
505
+
506
+ /**
507
+ * Reads a finite number with fallback.
508
+ * @param {unknown} value Candidate value.
509
+ * @param {number} fallback Fallback value.
510
+ * @returns {number}
511
+ */
512
+ static #number(value, fallback) {
513
+ const number = Number(value)
514
+ return Number.isFinite(number) ? number : fallback
515
+ }
516
+
517
+ /**
518
+ * Rounds generated numeric fields.
519
+ * @param {number} value Numeric value.
520
+ * @returns {number}
521
+ */
522
+ static #round(value) {
523
+ const rounded = Number(Number(value || 0).toFixed(6))
524
+ return Object.is(rounded, -0) ? 0 : rounded
525
+ }
526
+
527
+ /**
528
+ * Normalizes a degree angle to the 0-360 range.
529
+ * @param {number} angle Angle in degrees.
530
+ * @returns {number}
531
+ */
532
+ static #normalizeAngle(angle) {
533
+ return ((angle % 360) + 360) % 360
534
+ }
535
+
282
536
  /**
283
537
  * Builds one pipe-delimited record.
284
538
  * @param {Record<string, string>} fields Record fields.
@@ -67,6 +67,10 @@ export class AltiumPcbLibExporter {
67
67
  'Models/Data',
68
68
  AltiumLibraryRecordBuilder.buildModelsData(modelRows)
69
69
  )
70
+ streams.set(
71
+ 'ComponentBodies6/Data',
72
+ AltiumLibraryRecordBuilder.buildComponentBodiesData(modelRows)
73
+ )
70
74
  modelRows.forEach((row, index) => {
71
75
  streams.set('Models/' + index, row.model.bytes)
72
76
  })
@@ -78,11 +82,12 @@ export class AltiumPcbLibExporter {
78
82
  /**
79
83
  * Collects model rows with deterministic generated ids.
80
84
  * @param {object[]} bundles Normalized bundles.
81
- * @returns {{ model: object, id: string, checksum: number }[]}
85
+ * @returns {{ bundle: object, model: object, id: string, checksum: number }[]}
82
86
  */
83
87
  static #collectModels(bundles) {
84
88
  return bundles.flatMap((bundle, bundleIndex) =>
85
89
  bundle.models.map((model, modelIndex) => ({
90
+ bundle,
86
91
  model,
87
92
  id: 'model-' + bundleIndex + '-' + modelIndex,
88
93
  checksum: AltiumLibraryRecordBuilder.checksumBytes(model.bytes)
@@ -20,23 +20,128 @@ export class PcbComponentBodyPlacementNormalizer {
20
20
  const mirrorY = (value) =>
21
21
  Number(boardOutline?.minY || 0) + maxY - Number(value || 0)
22
22
 
23
- return componentBodies.map((componentBody) => ({
24
- ...componentBody,
25
- positionMil: {
26
- x: Number(componentBody.positionMil?.x || 0),
27
- y: mirrorY(componentBody.positionMil?.y || 0)
28
- },
29
- rotationDeg: PcbComponentBodyPlacementNormalizer.#normalizeAngle(
30
- 360 - Number(componentBody.rotationDeg || 0)
31
- ),
32
- modelRotationDeg: {
33
- x: Number(componentBody.modelRotationDeg?.x || 0),
34
- y: Number(componentBody.modelRotationDeg?.y || 0),
35
- z: PcbComponentBodyPlacementNormalizer.#normalizeAngle(
36
- 360 - Number(componentBody.modelRotationDeg?.z || 0)
37
- )
23
+ return componentBodies.map((componentBody) => {
24
+ const normalizedBody = {
25
+ ...componentBody,
26
+ positionMil: {
27
+ x: Number(componentBody.positionMil?.x || 0),
28
+ y: mirrorY(componentBody.positionMil?.y || 0)
29
+ },
30
+ rotationDeg:
31
+ PcbComponentBodyPlacementNormalizer.#normalizeAngle(
32
+ 360 - Number(componentBody.rotationDeg || 0)
33
+ ),
34
+ modelRotationDeg: {
35
+ x: Number(componentBody.modelRotationDeg?.x || 0),
36
+ y: Number(componentBody.modelRotationDeg?.y || 0),
37
+ z: PcbComponentBodyPlacementNormalizer.#normalizeAngle(
38
+ 360 - Number(componentBody.modelRotationDeg?.z || 0)
39
+ )
40
+ }
38
41
  }
42
+ const staticGeometry =
43
+ PcbComponentBodyPlacementNormalizer.#normalizeStaticGeometry(
44
+ componentBody,
45
+ mirrorY
46
+ )
47
+
48
+ return staticGeometry
49
+ ? {
50
+ ...normalizedBody,
51
+ staticGeometry
52
+ }
53
+ : normalizedBody
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Mirrors source-coordinate static geometry into the viewer coordinate
59
+ * system.
60
+ * @param {{ positionMil?: { x?: number, y?: number }, staticGeometry?: object }} componentBody Component body.
61
+ * @param {(value: number | string | undefined) => number} mirrorY Source Y mirror.
62
+ * @returns {object | undefined}
63
+ */
64
+ static #normalizeStaticGeometry(componentBody, mirrorY) {
65
+ const staticGeometry = componentBody?.staticGeometry
66
+ if (!staticGeometry || typeof staticGeometry !== 'object') {
67
+ return staticGeometry
68
+ }
69
+
70
+ const vertices = Array.isArray(staticGeometry.verticesMil)
71
+ ? staticGeometry.verticesMil
72
+ : []
73
+ if (
74
+ !PcbComponentBodyPlacementNormalizer.#shouldMirrorStaticVertices(
75
+ componentBody,
76
+ vertices
77
+ )
78
+ ) {
79
+ return staticGeometry
80
+ }
81
+
82
+ return {
83
+ ...staticGeometry,
84
+ verticesMil: vertices.map((vertex) => ({
85
+ ...vertex,
86
+ x: Number(vertex?.x || 0),
87
+ y: mirrorY(vertex?.y || 0)
88
+ }))
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Checks whether polygon vertices share the body source-coordinate frame.
94
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
95
+ * @param {{ x?: number, y?: number }[]} vertices Static polygon vertices.
96
+ * @returns {boolean}
97
+ */
98
+ static #shouldMirrorStaticVertices(componentBody, vertices) {
99
+ if (vertices.length < 3) {
100
+ return false
101
+ }
102
+
103
+ const center =
104
+ PcbComponentBodyPlacementNormalizer.#polygonBoundsCenter(vertices)
105
+ const source = {
106
+ x: Number(componentBody?.positionMil?.x || 0),
107
+ y: Number(componentBody?.positionMil?.y || 0)
108
+ }
109
+
110
+ return (
111
+ PcbComponentBodyPlacementNormalizer.#distance(center, source) <= 5
112
+ )
113
+ }
114
+
115
+ /**
116
+ * Resolves an axis-aligned polygon bounds center.
117
+ * @param {{ x?: number, y?: number }[]} vertices Static polygon vertices.
118
+ * @returns {{ x: number, y: number }}
119
+ */
120
+ static #polygonBoundsCenter(vertices) {
121
+ const points = vertices.map((vertex) => ({
122
+ x: Number(vertex?.x || 0),
123
+ y: Number(vertex?.y || 0)
39
124
  }))
125
+ const xs = points.map((point) => point.x)
126
+ const ys = points.map((point) => point.y)
127
+
128
+ return {
129
+ x: (Math.min(...xs) + Math.max(...xs)) / 2,
130
+ y: (Math.min(...ys) + Math.max(...ys)) / 2
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Measures XY distance between two points.
136
+ * @param {{ x?: number, y?: number }} first First point.
137
+ * @param {{ x?: number, y?: number }} second Second point.
138
+ * @returns {number}
139
+ */
140
+ static #distance(first, second) {
141
+ return Math.hypot(
142
+ Number(first?.x || 0) - Number(second?.x || 0),
143
+ Number(first?.y || 0) - Number(second?.y || 0)
144
+ )
40
145
  }
41
146
 
42
147
  /**
@@ -419,12 +419,23 @@ export class PcbEmbeddedModelExtractor {
419
419
  ? undefined
420
420
  : PcbEmbeddedModelExtractor.#decodeBodyColor(bodyColorRaw),
421
421
  bodyOpacity:
422
- bodyOpacity === null || !Number.isFinite(bodyOpacity)
423
- ? undefined
424
- : bodyOpacity
422
+ PcbEmbeddedModelExtractor.#normalizeBodyOpacity(bodyOpacity)
425
423
  })
426
424
  }
427
425
 
426
+ /**
427
+ * Normalizes native body opacity metadata to authored translucency only.
428
+ * @param {number | null} bodyOpacity Native body opacity value.
429
+ * @returns {number | undefined}
430
+ */
431
+ static #normalizeBodyOpacity(bodyOpacity) {
432
+ if (!Number.isFinite(bodyOpacity) || Number(bodyOpacity) <= 0) {
433
+ return undefined
434
+ }
435
+
436
+ return bodyOpacity
437
+ }
438
+
428
439
  /**
429
440
  * Maps a numeric body model type to a stable semantic label.
430
441
  * @param {number} modelType Numeric model type.
@@ -121,10 +121,11 @@ export class PcbShapeBasedBodyGeometryParser {
121
121
  }
122
122
 
123
123
  const textBytes = bytes.subarray(offset + 22, offset + 22 + textLength)
124
- const text = PrintableTextDecoder.decodeBytes(textBytes).replaceAll(
125
- '\u0000',
126
- ''
127
- )
124
+ const payload =
125
+ PcbShapeBasedBodyGeometryParser.#splitInlineVertexPayload(textBytes)
126
+ const text = PrintableTextDecoder.decodeBytes(
127
+ payload.fieldBytes
128
+ ).replaceAll('\u0000', '')
128
129
 
129
130
  if (
130
131
  !text.includes('MODEL.') ||
@@ -133,11 +134,17 @@ export class PcbShapeBasedBodyGeometryParser {
133
134
  return null
134
135
  }
135
136
 
136
- const fields =
137
- PcbShapeBasedBodyGeometryParser.#parseFieldRecordBytes(textBytes)
137
+ const fields = PcbShapeBasedBodyGeometryParser.#parseFieldRecordBytes(
138
+ payload.fieldBytes
139
+ )
140
+ const textEndOffset = offset + 22 + textLength
141
+ const vertexBlockOffset =
142
+ payload.inlineVertexBlockOffset === null
143
+ ? textEndOffset
144
+ : offset + 22 + payload.inlineVertexBlockOffset
138
145
  const vertexBlock = PcbShapeBasedBodyGeometryParser.#parseVertexBlock(
139
146
  bytes,
140
- offset + 22 + textLength
147
+ vertexBlockOffset
141
148
  )
142
149
 
143
150
  return {
@@ -146,13 +153,80 @@ export class PcbShapeBasedBodyGeometryParser {
146
153
  fields,
147
154
  vertexBlock.verticesMil
148
155
  ),
149
- nextOffset:
150
- vertexBlock.nextOffset > offset
151
- ? vertexBlock.nextOffset
152
- : offset + 22 + textLength
156
+ nextOffset: Math.max(vertexBlock.nextOffset, textEndOffset)
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Splits printable fields from Altium records whose text length also
162
+ * covers an inline binary vertex block after a NUL terminator.
163
+ * @param {Uint8Array} textBytes Declared text payload bytes.
164
+ * @returns {{ fieldBytes: Uint8Array, inlineVertexBlockOffset: number | null }}
165
+ */
166
+ static #splitInlineVertexPayload(textBytes) {
167
+ const inlineVertexBlockOffset =
168
+ PcbShapeBasedBodyGeometryParser.#inlineVertexBlockOffset(textBytes)
169
+ if (inlineVertexBlockOffset === null) {
170
+ return { fieldBytes: textBytes, inlineVertexBlockOffset: null }
171
+ }
172
+
173
+ return {
174
+ fieldBytes: textBytes.subarray(0, inlineVertexBlockOffset - 1),
175
+ inlineVertexBlockOffset
153
176
  }
154
177
  }
155
178
 
179
+ /**
180
+ * Finds the inline vertex-count offset inside a declared text payload.
181
+ * @param {Uint8Array} textBytes Declared text payload bytes.
182
+ * @returns {number | null}
183
+ */
184
+ static #inlineVertexBlockOffset(textBytes) {
185
+ for (let index = 0; index < textBytes.byteLength; index += 1) {
186
+ if (textBytes[index] !== 0) {
187
+ continue
188
+ }
189
+
190
+ const vertexBlockOffset = index + 1
191
+ if (
192
+ PcbShapeBasedBodyGeometryParser.#hasPlausibleVertexBlock(
193
+ textBytes,
194
+ vertexBlockOffset
195
+ )
196
+ ) {
197
+ return vertexBlockOffset
198
+ }
199
+ }
200
+
201
+ return null
202
+ }
203
+
204
+ /**
205
+ * Checks whether one byte offset points to a complete vertex block.
206
+ * @param {Uint8Array} bytes Candidate payload bytes.
207
+ * @param {number} offset Candidate vertex-count offset.
208
+ * @returns {boolean}
209
+ */
210
+ static #hasPlausibleVertexBlock(bytes, offset) {
211
+ if (offset + 4 > bytes.byteLength) {
212
+ return false
213
+ }
214
+
215
+ const view = new DataView(
216
+ bytes.buffer,
217
+ bytes.byteOffset,
218
+ bytes.byteLength
219
+ )
220
+ const vertexCount = view.getInt32(offset, true)
221
+ const dataOffset = offset + 4
222
+
223
+ return (
224
+ vertexCount >= 3 &&
225
+ vertexCount <= 10000 &&
226
+ dataOffset + vertexCount * 37 <= bytes.byteLength
227
+ )
228
+ }
229
+
156
230
  /**
157
231
  * Parses the optional coordinate block after a shape-body field record.
158
232
  * @param {Uint8Array} bytes Stream bytes.
@@ -215,7 +215,8 @@ export class PrintableTextDecoder {
215
215
  * @returns {boolean}
216
216
  */
217
217
  static #hasWindows1252PreferredBytes(bytes) {
218
- for (const byte of bytes) {
218
+ for (let index = 0; index < bytes.length; index += 1) {
219
+ const byte = bytes[index]
219
220
  if (
220
221
  PrintableTextDecoder.#WINDOWS_1252_PRINTABLE_CONTROL_BYTES.has(
221
222
  byte
@@ -223,11 +224,29 @@ export class PrintableTextDecoder {
223
224
  ) {
224
225
  return true
225
226
  }
227
+
228
+ if (
229
+ byte >= 0xc0 &&
230
+ byte <= 0xff &&
231
+ (PrintableTextDecoder.#isAsciiLetter(bytes[index - 1]) ||
232
+ PrintableTextDecoder.#isAsciiLetter(bytes[index + 1]))
233
+ ) {
234
+ return true
235
+ }
226
236
  }
227
237
 
228
238
  return false
229
239
  }
230
240
 
241
+ /**
242
+ * Returns true when one byte is an ASCII letter.
243
+ * @param {number | undefined} byte Byte value.
244
+ * @returns {boolean}
245
+ */
246
+ static #isAsciiLetter(byte) {
247
+ return (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a)
248
+ }
249
+
231
250
  /**
232
251
  * Trims ASCII whitespace from one byte range.
233
252
  * @param {Uint8Array} bytes