altium-toolkit 1.1.33 → 1.1.35

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.
@@ -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.35",
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.
@@ -137,10 +137,25 @@ export class SourceComponentBundleNormalizer {
137
137
  primitives: SourceComponentBundleNormalizer.#array(
138
138
  footprint.primitives || footprint.shapes
139
139
  ),
140
+ component:
141
+ SourceComponentBundleNormalizer.#normalizeFootprintComponent(
142
+ footprint.component || source.component
143
+ ),
140
144
  raw: footprint
141
145
  }
142
146
  }
143
147
 
148
+ /**
149
+ * Preserves source footprint component placement metadata for exporters.
150
+ * @param {object | undefined} component Source component placement.
151
+ * @returns {object}
152
+ */
153
+ static #normalizeFootprintComponent(component) {
154
+ return component && typeof component === 'object'
155
+ ? { ...component }
156
+ : {}
157
+ }
158
+
144
159
  /**
145
160
  * Normalizes model asset descriptors.
146
161
  * @param {object} source Unwrapped response.
@@ -177,6 +192,8 @@ export class SourceComponentBundleNormalizer {
177
192
  )
178
193
  const bytes =
179
194
  SourceComponentBundleNormalizer.#normalizeModelBytes(model)
195
+ const transform =
196
+ SourceComponentBundleNormalizer.#normalizeModelTransform(model)
180
197
 
181
198
  return {
182
199
  id: SourceComponentBundleNormalizer.#firstString(
@@ -199,10 +216,24 @@ export class SourceComponentBundleNormalizer {
199
216
  model.downloadUrl,
200
217
  model.sourceUrl
201
218
  ),
219
+ ...(transform ? { transform } : {}),
220
+ ...(model.generated === true ? { generated: true } : {}),
202
221
  raw: model
203
222
  }
204
223
  }
205
224
 
225
+ /**
226
+ * Preserves optional model placement transforms.
227
+ * @param {object} model Raw model asset.
228
+ * @returns {object | null}
229
+ */
230
+ static #normalizeModelTransform(model) {
231
+ const transform = model.transform || model.modelTransform || null
232
+ return transform && typeof transform === 'object'
233
+ ? { ...transform }
234
+ : null
235
+ }
236
+
206
237
  /**
207
238
  * Normalizes one model payload into bytes.
208
239
  * @param {object} model Raw model asset.
@@ -6,7 +6,7 @@
6
6
  * Defines the Circuit JSON contract emitted by public parser roots.
7
7
  */
8
8
  export class CircuitJsonModelSchema {
9
- static CURRENT_SCHEMA_ID = 'https://github.com/tscircuit/circuit-json'
9
+ static CURRENT_SCHEMA_ID = 'urn:altium-toolkit:circuit-json:0.0.433'
10
10
 
11
11
  static CURRENT_SCHEMA_VERSION = '0.0.433'
12
12