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.
- package/docs/model-format.md +2 -3
- package/package.json +1 -1
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +255 -1
- package/src/core/altium/AltiumPcbLibExporter.mjs +6 -1
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +14 -3
- package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
- package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +6 -2
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +82 -3
- package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
- package/src/ui/PcbScene3dBuilder.mjs +470 -20
- package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
- package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
- package/src/ui/PcbScene3dPackages.mjs +15 -3
- package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +335 -29
- package/src/ui/PcbScene3dStaticBodyRecovery.mjs +891 -0
- package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +358 -0
- package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +876 -0
package/docs/model-format.md
CHANGED
|
@@ -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
|
|
84
|
-
|
|
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
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 = '
|
|
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
|
|