altium-toolkit 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/api.md +6 -2
- package/docs/model-format.md +29 -4
- package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +80 -0
- package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
- package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
- package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +166 -0
- package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
- package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
- package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
- package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +856 -7
- package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
- package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
- package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
- package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
- package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
- package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
- package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
- package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
- package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
- package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
- package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
- package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
- package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
- package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
- package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
- package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
- package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
- package/package.json +1 -1
- package/src/core/altium/AltiumParser.mjs +12 -2
- package/src/core/altium/CiArtifactBundleBuilder.mjs +213 -0
- package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
- package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
- package/src/core/altium/DraftsmanDigestParser.mjs +928 -0
- package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
- package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
- package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
- package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
- package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
- package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
- package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
- package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
- package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
- package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
- package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
- package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
- package/src/core/altium/PcbLibModelParser.mjs +2 -0
- package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
- package/src/core/altium/PcbModelParser.mjs +211 -22
- package/src/core/altium/PcbPadStackParser.mjs +171 -2
- package/src/core/altium/PcbPickPlacePositionResolver.mjs +11 -1
- package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
- package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
- package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
- package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
- package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
- package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
- package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
- package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
- package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
- package/src/core/altium/PrintableTextDecoder.mjs +70 -6
- package/src/core/altium/PrjPcbModelParser.mjs +69 -2
- package/src/core/altium/PrjScrModelParser.mjs +386 -0
- package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +76 -2
- package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
- package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
- package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
- package/src/core/altium/SvgModelCrossLinkValidator.mjs +435 -0
- package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +300 -96
- package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/parser.mjs +21 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
- package/src/ui/PcbScene3dBuilder.mjs +26 -4
- package/src/ui/PcbSvgRenderer.mjs +65 -0
- package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
- package/src/ui/SchematicSvgRenderer.mjs +48 -2
|
@@ -81,6 +81,10 @@ export class PcbPadStackParser {
|
|
|
81
81
|
const flags = PcbPadStackParser.#parseFlags(mainRecord)
|
|
82
82
|
const mainRecordTail =
|
|
83
83
|
PcbPadStackParser.#parseMainRecordTail(mainRecord)
|
|
84
|
+
const extension = PcbPadStackParser.#parseExtensionRecord(
|
|
85
|
+
extensionRecord,
|
|
86
|
+
padContext
|
|
87
|
+
)
|
|
84
88
|
|
|
85
89
|
return {
|
|
86
90
|
...flags,
|
|
@@ -90,8 +94,10 @@ export class PcbPadStackParser {
|
|
|
90
94
|
mainRecordTail,
|
|
91
95
|
padContext
|
|
92
96
|
),
|
|
93
|
-
...
|
|
94
|
-
|
|
97
|
+
...extension,
|
|
98
|
+
...PcbPadStackParser.#buildLocalStack(
|
|
99
|
+
mainRecordTail,
|
|
100
|
+
extension,
|
|
95
101
|
padContext
|
|
96
102
|
)
|
|
97
103
|
}
|
|
@@ -722,6 +728,169 @@ export class PcbPadStackParser {
|
|
|
722
728
|
}
|
|
723
729
|
}
|
|
724
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Builds a normalized local-stack geometry read model.
|
|
733
|
+
* @param {Record<string, boolean | number>} mainRecordTail Main tail fields.
|
|
734
|
+
* @param {Record<string, unknown>} extension Extension fields.
|
|
735
|
+
* @param {Record<string, unknown>} padContext Parsed pad fields.
|
|
736
|
+
* @returns {{ localStack?: object }}
|
|
737
|
+
*/
|
|
738
|
+
static #buildLocalStack(mainRecordTail, extension, padContext) {
|
|
739
|
+
const mode = Number(mainRecordTail.padMode)
|
|
740
|
+
if (mode === 1) {
|
|
741
|
+
return {
|
|
742
|
+
localStack: {
|
|
743
|
+
schema: 'altium-toolkit.pcb.pad-local-stack.a1',
|
|
744
|
+
mode,
|
|
745
|
+
modeName: String(mainRecordTail.padModeName || ''),
|
|
746
|
+
source: 'main-record',
|
|
747
|
+
layers: [
|
|
748
|
+
PcbPadStackParser.#localStackLayer(
|
|
749
|
+
'top',
|
|
750
|
+
1,
|
|
751
|
+
'L1',
|
|
752
|
+
padContext,
|
|
753
|
+
extension
|
|
754
|
+
),
|
|
755
|
+
PcbPadStackParser.#localStackLayer(
|
|
756
|
+
'middle',
|
|
757
|
+
null,
|
|
758
|
+
'INNER',
|
|
759
|
+
padContext,
|
|
760
|
+
extension
|
|
761
|
+
),
|
|
762
|
+
PcbPadStackParser.#localStackLayer(
|
|
763
|
+
'bottom',
|
|
764
|
+
32,
|
|
765
|
+
'L32',
|
|
766
|
+
padContext,
|
|
767
|
+
extension
|
|
768
|
+
)
|
|
769
|
+
],
|
|
770
|
+
hole: PcbPadStackParser.#localStackHole(
|
|
771
|
+
padContext,
|
|
772
|
+
extension
|
|
773
|
+
)
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (
|
|
779
|
+
mode === 2 &&
|
|
780
|
+
Array.isArray(extension.fullStackLayerEntries) &&
|
|
781
|
+
extension.fullStackLayerEntries.length
|
|
782
|
+
) {
|
|
783
|
+
return {
|
|
784
|
+
localStack: {
|
|
785
|
+
schema: 'altium-toolkit.pcb.pad-local-stack.a1',
|
|
786
|
+
mode,
|
|
787
|
+
modeName: String(mainRecordTail.padModeName || ''),
|
|
788
|
+
source: 'extension-record',
|
|
789
|
+
layers: extension.fullStackLayerEntries.map((entry) => ({
|
|
790
|
+
role: 'layer',
|
|
791
|
+
layerId: Number(entry.layerCode),
|
|
792
|
+
layerKey: 'L' + Number(entry.layerCode),
|
|
793
|
+
enabled: entry.enabled,
|
|
794
|
+
width: entry.sizeX,
|
|
795
|
+
height: entry.sizeY,
|
|
796
|
+
cornerRadius: entry.cornerRadius,
|
|
797
|
+
modeFlags: entry.modeFlags
|
|
798
|
+
})),
|
|
799
|
+
hole: PcbPadStackParser.#localStackHole(
|
|
800
|
+
padContext,
|
|
801
|
+
extension
|
|
802
|
+
)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return {}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Builds one top/middle/bottom local-stack layer entry.
|
|
812
|
+
* @param {'top' | 'middle' | 'bottom'} role Layer role.
|
|
813
|
+
* @param {number | null} layerId Layer id.
|
|
814
|
+
* @param {string} layerKey Stable layer key.
|
|
815
|
+
* @param {Record<string, unknown>} padContext Parsed pad fields.
|
|
816
|
+
* @param {Record<string, unknown>} extension Extension fields.
|
|
817
|
+
* @returns {object}
|
|
818
|
+
*/
|
|
819
|
+
static #localStackLayer(role, layerId, layerKey, padContext, extension) {
|
|
820
|
+
const suffix =
|
|
821
|
+
role === 'top' ? 'Top' : role === 'bottom' ? 'Bottom' : 'Mid'
|
|
822
|
+
const offset = PcbPadStackParser.#layerOffset(role, extension)
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
role,
|
|
826
|
+
layerId,
|
|
827
|
+
layerKey,
|
|
828
|
+
width: Number(padContext['size' + suffix + 'X'] || 0),
|
|
829
|
+
height: Number(padContext['size' + suffix + 'Y'] || 0),
|
|
830
|
+
shape: PcbPadStackParser.#numericOrNull(
|
|
831
|
+
padContext['shape' + suffix]
|
|
832
|
+
),
|
|
833
|
+
shapeName: PcbPadShapeCodec.padShapeName(
|
|
834
|
+
padContext['shape' + suffix]
|
|
835
|
+
),
|
|
836
|
+
offsetX: offset.x,
|
|
837
|
+
offsetY: offset.y
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Resolves layer offsets from extension data when present.
|
|
843
|
+
* @param {'top' | 'middle' | 'bottom'} role Layer role.
|
|
844
|
+
* @param {Record<string, unknown>} extension Extension fields.
|
|
845
|
+
* @returns {{ x: number, y: number }}
|
|
846
|
+
*/
|
|
847
|
+
static #layerOffset(role, extension) {
|
|
848
|
+
const layerNumber = role === 'top' ? 1 : role === 'bottom' ? 32 : null
|
|
849
|
+
const offset = Array.isArray(extension.layerOffsets)
|
|
850
|
+
? extension.layerOffsets.find(
|
|
851
|
+
(entry) => entry.layerNumber === layerNumber
|
|
852
|
+
)
|
|
853
|
+
: null
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
x: Number(offset?.x || 0),
|
|
857
|
+
y: Number(offset?.y || 0)
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Builds local-stack hole geometry.
|
|
863
|
+
* @param {Record<string, unknown>} padContext Parsed pad fields.
|
|
864
|
+
* @param {Record<string, unknown>} extension Extension fields.
|
|
865
|
+
* @returns {object}
|
|
866
|
+
*/
|
|
867
|
+
static #localStackHole(padContext, extension) {
|
|
868
|
+
const shape = PcbPadStackParser.#numericOrNull(extension.holeShape)
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
diameter: Number(padContext.holeDiameter || 0),
|
|
872
|
+
shape,
|
|
873
|
+
shapeName:
|
|
874
|
+
shape === null ? null : PcbPadShapeCodec.holeShapeName(shape),
|
|
875
|
+
slotLength: extension.holeSlotLength ?? null,
|
|
876
|
+
rotation: extension.holeRotation ?? null
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Converts finite numeric values and nullish values into stable output.
|
|
882
|
+
* @param {unknown} value Candidate value.
|
|
883
|
+
* @returns {number | null}
|
|
884
|
+
*/
|
|
885
|
+
static #numericOrNull(value) {
|
|
886
|
+
if (value === null || value === undefined || value === '') {
|
|
887
|
+
return null
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const number = Number(value)
|
|
891
|
+
return Number.isFinite(number) ? number : null
|
|
892
|
+
}
|
|
893
|
+
|
|
725
894
|
/**
|
|
726
895
|
* Decodes non-empty inner-layer pad sizes.
|
|
727
896
|
* @param {DataView} extensionRecord
|
|
@@ -15,10 +15,16 @@ export class PcbPickPlacePositionResolver {
|
|
|
15
15
|
* @param {{ componentIndex: number, designator: string, pattern: string, layer: string, rotation: number, x: number, y: number }[]} components
|
|
16
16
|
* @param {{ componentIndex: number, pads?: { x?: number, y?: number }[] }[]} componentPrimitiveGroups
|
|
17
17
|
* @param {{ sourceComponents?: { componentIndex: number, rotation?: number }[] }} [options] Resolver options.
|
|
18
|
-
* @returns {{ positionMode: string, entries: object[], modes: { componentOrigin: { positionMode: string, entries: object[] } } }}
|
|
18
|
+
* @returns {{ units: object, positionMode: string, entries: object[], modes: { componentOrigin: { units: object, positionMode: string, entries: object[] } } }}
|
|
19
19
|
*/
|
|
20
20
|
static buildModel(components, componentPrimitiveGroups, options = {}) {
|
|
21
|
+
const units = {
|
|
22
|
+
coordinate: 'mil',
|
|
23
|
+
angle: 'deg'
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
return {
|
|
27
|
+
units,
|
|
22
28
|
positionMode: DEFAULT_POSITION_MODE,
|
|
23
29
|
entries: PcbPickPlacePositionResolver.buildEntries(
|
|
24
30
|
components,
|
|
@@ -28,6 +34,7 @@ export class PcbPickPlacePositionResolver {
|
|
|
28
34
|
),
|
|
29
35
|
modes: {
|
|
30
36
|
componentOrigin: {
|
|
37
|
+
units,
|
|
31
38
|
positionMode: COMPONENT_ORIGIN_MODE,
|
|
32
39
|
entries: PcbPickPlacePositionResolver.buildEntries(
|
|
33
40
|
components,
|
|
@@ -137,6 +144,9 @@ export class PcbPickPlacePositionResolver {
|
|
|
137
144
|
designator: component.designator || '',
|
|
138
145
|
pattern: component.pattern || '',
|
|
139
146
|
layer: component.layer || '',
|
|
147
|
+
...(component.componentKind
|
|
148
|
+
? { componentKind: component.componentKind }
|
|
149
|
+
: {}),
|
|
140
150
|
rotation: PcbPickPlacePositionResolver.#roundCoordinate(rotation),
|
|
141
151
|
x: PcbPickPlacePositionResolver.#roundCoordinate(position.x),
|
|
142
152
|
y: PcbPickPlacePositionResolver.#roundCoordinate(position.y),
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds read-only extraction manifests for placed PCB footprints.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbPlacedFootprintManifestBuilder {
|
|
9
|
+
static SCHEMA = 'altium-toolkit.pcb.placed-footprint-extraction.a1'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a placed-footprint extraction manifest.
|
|
13
|
+
* @param {{ fileName?: string, components?: object[], componentPrimitiveGroups?: object[], embeddedModels?: object[] }} context Manifest context.
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
static build(context = {}) {
|
|
17
|
+
const outputs = (context.componentPrimitiveGroups || []).map(
|
|
18
|
+
(group, index) =>
|
|
19
|
+
PcbPlacedFootprintManifestBuilder.#output(context, group, index)
|
|
20
|
+
)
|
|
21
|
+
const embeddedAssetCount = outputs.reduce(
|
|
22
|
+
(total, output) => total + output.embeddedAssets.length,
|
|
23
|
+
0
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
schema: PcbPlacedFootprintManifestBuilder.SCHEMA,
|
|
28
|
+
sourceDocument: String(context.fileName || ''),
|
|
29
|
+
summary: {
|
|
30
|
+
componentCount: (context.components || []).length,
|
|
31
|
+
extractableFootprintCount: outputs.length,
|
|
32
|
+
embeddedAssetCount
|
|
33
|
+
},
|
|
34
|
+
outputs,
|
|
35
|
+
indexes: PcbPlacedFootprintManifestBuilder.#indexes(outputs)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds one placed-footprint output descriptor.
|
|
41
|
+
* @param {object} context Manifest context.
|
|
42
|
+
* @param {object} group Component primitive group.
|
|
43
|
+
* @param {number} index Group index.
|
|
44
|
+
* @returns {object}
|
|
45
|
+
*/
|
|
46
|
+
static #output(context, group, index) {
|
|
47
|
+
const component =
|
|
48
|
+
(context.components || []).find(
|
|
49
|
+
(candidate) =>
|
|
50
|
+
Number(candidate.componentIndex) ===
|
|
51
|
+
Number(group.componentIndex)
|
|
52
|
+
) || {}
|
|
53
|
+
const designator = group.designator || component.designator || ''
|
|
54
|
+
const pattern = component.pattern || ''
|
|
55
|
+
const footprintKey =
|
|
56
|
+
'footprint-extract-' +
|
|
57
|
+
index +
|
|
58
|
+
'-' +
|
|
59
|
+
PcbPlacedFootprintManifestBuilder.#slug(
|
|
60
|
+
[designator, pattern].filter(Boolean).join('-') || index
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
kind: 'placed-footprint',
|
|
65
|
+
footprintKey,
|
|
66
|
+
designator,
|
|
67
|
+
pattern,
|
|
68
|
+
componentIndex: Number(group.componentIndex),
|
|
69
|
+
outputLibraryKey: 'pcb-extract/' + footprintKey + '.PcbLib',
|
|
70
|
+
renderManifestKey: 'pcb-extract/' + footprintKey + '.render.json',
|
|
71
|
+
primitiveCounts:
|
|
72
|
+
PcbPlacedFootprintManifestBuilder.#primitiveCounts(group),
|
|
73
|
+
layers: PcbPlacedFootprintManifestBuilder.#layers(group),
|
|
74
|
+
embeddedAssets: PcbPlacedFootprintManifestBuilder.#embeddedAssets(
|
|
75
|
+
group,
|
|
76
|
+
context.embeddedModels || []
|
|
77
|
+
),
|
|
78
|
+
diagnostics: PcbPlacedFootprintManifestBuilder.#diagnostics(group)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Counts footprint-owned primitive families.
|
|
84
|
+
* @param {object} group Component primitive group.
|
|
85
|
+
* @returns {object}
|
|
86
|
+
*/
|
|
87
|
+
static #primitiveCounts(group) {
|
|
88
|
+
return {
|
|
89
|
+
pads: (group.pads || []).length,
|
|
90
|
+
tracks: (group.tracks || []).length,
|
|
91
|
+
arcs: (group.arcs || []).length,
|
|
92
|
+
fills: (group.fills || []).length,
|
|
93
|
+
vias: (group.vias || []).length,
|
|
94
|
+
regions: (group.regions || []).length,
|
|
95
|
+
shapeBasedRegions: (group.shapeBasedRegions || []).length,
|
|
96
|
+
texts: (group.texts || []).length,
|
|
97
|
+
componentBodies: (group.componentBodies || []).length
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Builds layer descriptors touched by one footprint.
|
|
103
|
+
* @param {object} group Component primitive group.
|
|
104
|
+
* @returns {object[]}
|
|
105
|
+
*/
|
|
106
|
+
static #layers(group) {
|
|
107
|
+
const layerMap = new Map()
|
|
108
|
+
for (const primitive of PcbPlacedFootprintManifestBuilder.#primitives(
|
|
109
|
+
group
|
|
110
|
+
)) {
|
|
111
|
+
const layer =
|
|
112
|
+
PcbPlacedFootprintManifestBuilder.#layerDescriptor(primitive)
|
|
113
|
+
if (layer) {
|
|
114
|
+
layerMap.set(layer.layerKey, layer)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...layerMap.values()].sort((left, right) =>
|
|
118
|
+
left.layerKey.localeCompare(right.layerKey, undefined, {
|
|
119
|
+
numeric: true
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Collects embedded assets referenced by component bodies.
|
|
126
|
+
* @param {object} group Component primitive group.
|
|
127
|
+
* @param {object[]} embeddedModels Embedded model rows.
|
|
128
|
+
* @returns {object[]}
|
|
129
|
+
*/
|
|
130
|
+
static #embeddedAssets(group, embeddedModels) {
|
|
131
|
+
return PcbPlacedFootprintManifestBuilder.#dedupe(
|
|
132
|
+
(group.componentBodies || []).flatMap((body) => {
|
|
133
|
+
const model =
|
|
134
|
+
PcbPlacedFootprintManifestBuilder.#matchingModel(
|
|
135
|
+
body,
|
|
136
|
+
embeddedModels
|
|
137
|
+
) || body
|
|
138
|
+
return [
|
|
139
|
+
PcbPlacedFootprintManifestBuilder.#stripUndefined({
|
|
140
|
+
key: model.id || body.modelId || model.name,
|
|
141
|
+
format: model.format,
|
|
142
|
+
sourceStream: model.sourceStream,
|
|
143
|
+
name: model.name || body.name
|
|
144
|
+
})
|
|
145
|
+
]
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Builds extraction diagnostics for one group.
|
|
152
|
+
* @param {object} group Component primitive group.
|
|
153
|
+
* @returns {object[]}
|
|
154
|
+
*/
|
|
155
|
+
static #diagnostics(group) {
|
|
156
|
+
const diagnostics = []
|
|
157
|
+
if (!PcbPlacedFootprintManifestBuilder.#hasOwnedGeometry(group)) {
|
|
158
|
+
diagnostics.push({
|
|
159
|
+
code: 'pcb-footprint-extract.empty-geometry',
|
|
160
|
+
severity: 'warning',
|
|
161
|
+
message: 'Placed component has no owned footprint geometry.'
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
return diagnostics
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Builds manifest lookup indexes.
|
|
169
|
+
* @param {object[]} outputs Output descriptors.
|
|
170
|
+
* @returns {object}
|
|
171
|
+
*/
|
|
172
|
+
static #indexes(outputs) {
|
|
173
|
+
const outputsByDesignator = {}
|
|
174
|
+
const outputsByPattern = {}
|
|
175
|
+
|
|
176
|
+
outputs.forEach((output, index) => {
|
|
177
|
+
if (output.designator)
|
|
178
|
+
outputsByDesignator[output.designator] = index
|
|
179
|
+
if (output.pattern) {
|
|
180
|
+
outputsByPattern[output.pattern] =
|
|
181
|
+
outputsByPattern[output.pattern] || []
|
|
182
|
+
outputsByPattern[output.pattern].push(index)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
outputsByDesignator,
|
|
188
|
+
outputsByPattern
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Returns all geometry primitives from a group.
|
|
194
|
+
* @param {object} group Component primitive group.
|
|
195
|
+
* @returns {object[]}
|
|
196
|
+
*/
|
|
197
|
+
static #primitives(group) {
|
|
198
|
+
return [
|
|
199
|
+
...(group.pads || []),
|
|
200
|
+
...(group.tracks || []),
|
|
201
|
+
...(group.arcs || []),
|
|
202
|
+
...(group.fills || []),
|
|
203
|
+
...(group.vias || []),
|
|
204
|
+
...(group.regions || []),
|
|
205
|
+
...(group.shapeBasedRegions || []),
|
|
206
|
+
...(group.texts || [])
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Returns true when a component has any extractable geometry.
|
|
212
|
+
* @param {object} group Component primitive group.
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
static #hasOwnedGeometry(group) {
|
|
216
|
+
return (
|
|
217
|
+
PcbPlacedFootprintManifestBuilder.#primitives(group).length > 0 ||
|
|
218
|
+
(group.componentBodies || []).length > 0
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Builds a normalized layer descriptor.
|
|
224
|
+
* @param {object} primitive Primitive row.
|
|
225
|
+
* @returns {object | null}
|
|
226
|
+
*/
|
|
227
|
+
static #layerDescriptor(primitive) {
|
|
228
|
+
const layerId = Number.isInteger(primitive?.layerId)
|
|
229
|
+
? primitive.layerId
|
|
230
|
+
: null
|
|
231
|
+
const displayName = String(
|
|
232
|
+
primitive?.layerName || primitive?.layer || ''
|
|
233
|
+
).trim()
|
|
234
|
+
if (layerId === null && !displayName) {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
const layerKey =
|
|
238
|
+
layerId === null
|
|
239
|
+
? 'layer-' +
|
|
240
|
+
PcbPlacedFootprintManifestBuilder.#slug(displayName)
|
|
241
|
+
: 'L' + layerId
|
|
242
|
+
|
|
243
|
+
return PcbPlacedFootprintManifestBuilder.#stripUndefined({
|
|
244
|
+
layerKey,
|
|
245
|
+
layerId: layerId === null ? undefined : layerId,
|
|
246
|
+
displayName: displayName || layerKey
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolves an embedded model row for one component body.
|
|
252
|
+
* @param {object} body Component body row.
|
|
253
|
+
* @param {object[]} embeddedModels Embedded model rows.
|
|
254
|
+
* @returns {object | null}
|
|
255
|
+
*/
|
|
256
|
+
static #matchingModel(body, embeddedModels) {
|
|
257
|
+
return (
|
|
258
|
+
(embeddedModels || []).find(
|
|
259
|
+
(model) =>
|
|
260
|
+
PcbPlacedFootprintManifestBuilder.#same(
|
|
261
|
+
model.id,
|
|
262
|
+
body.modelId
|
|
263
|
+
) ||
|
|
264
|
+
PcbPlacedFootprintManifestBuilder.#same(
|
|
265
|
+
model.checksum,
|
|
266
|
+
body.checksum
|
|
267
|
+
) ||
|
|
268
|
+
PcbPlacedFootprintManifestBuilder.#same(
|
|
269
|
+
model.name,
|
|
270
|
+
body.name
|
|
271
|
+
)
|
|
272
|
+
) || null
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Compares two non-empty values.
|
|
278
|
+
* @param {unknown} left First value.
|
|
279
|
+
* @param {unknown} right Second value.
|
|
280
|
+
* @returns {boolean}
|
|
281
|
+
*/
|
|
282
|
+
static #same(left, right) {
|
|
283
|
+
return (
|
|
284
|
+
left !== null &&
|
|
285
|
+
left !== undefined &&
|
|
286
|
+
left !== '' &&
|
|
287
|
+
right !== null &&
|
|
288
|
+
right !== undefined &&
|
|
289
|
+
right !== '' &&
|
|
290
|
+
String(left) === String(right)
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Deduplicates objects by their JSON form.
|
|
296
|
+
* @param {object[]} rows Candidate rows.
|
|
297
|
+
* @returns {object[]}
|
|
298
|
+
*/
|
|
299
|
+
static #dedupe(rows) {
|
|
300
|
+
const seen = new Set()
|
|
301
|
+
const deduped = []
|
|
302
|
+
for (const row of rows || []) {
|
|
303
|
+
const key = JSON.stringify(row)
|
|
304
|
+
if (seen.has(key)) continue
|
|
305
|
+
seen.add(key)
|
|
306
|
+
deduped.push(row)
|
|
307
|
+
}
|
|
308
|
+
return deduped
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Converts a value to a deterministic lowercase key segment.
|
|
313
|
+
* @param {unknown} value Source value.
|
|
314
|
+
* @returns {string}
|
|
315
|
+
*/
|
|
316
|
+
static #slug(value) {
|
|
317
|
+
return (
|
|
318
|
+
String(value || '')
|
|
319
|
+
.trim()
|
|
320
|
+
.toLowerCase()
|
|
321
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
322
|
+
.replace(/^-+|-+$/gu, '') || 'item'
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Removes undefined fields from one object.
|
|
328
|
+
* @param {Record<string, unknown>} value Candidate object.
|
|
329
|
+
* @returns {Record<string, unknown>}
|
|
330
|
+
*/
|
|
331
|
+
static #stripUndefined(value) {
|
|
332
|
+
return Object.fromEntries(
|
|
333
|
+
Object.entries(value || {}).filter(
|
|
334
|
+
([, entryValue]) => entryValue !== undefined
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { AltiumLayoutParser } from './AltiumLayoutParser.mjs'
|
|
6
|
+
import { ParserUtils } from './ParserUtils.mjs'
|
|
7
|
+
|
|
8
|
+
const { getField, parseBoolean, parseNumericField } = ParserUtils
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalizes printable PCB polygon records into renderable polygon rows.
|
|
12
|
+
*/
|
|
13
|
+
export class PcbPolygonRecordParser {
|
|
14
|
+
/**
|
|
15
|
+
* Parses polygon rows from printable records.
|
|
16
|
+
* @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records Printable records.
|
|
17
|
+
* @returns {object[]}
|
|
18
|
+
*/
|
|
19
|
+
static parse(records) {
|
|
20
|
+
return (records || [])
|
|
21
|
+
.filter(
|
|
22
|
+
(record) =>
|
|
23
|
+
record.sourceStream === 'Polygons6/Data' &&
|
|
24
|
+
getField(record.fields, 'KIND0')
|
|
25
|
+
)
|
|
26
|
+
.map((record, index) =>
|
|
27
|
+
PcbPolygonRecordParser.#normalize(record.fields, index)
|
|
28
|
+
)
|
|
29
|
+
.filter((polygon) => polygon.segments.length > 0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalizes one printable polygon row.
|
|
34
|
+
* @param {Record<string, string | string[]>} fields Record fields.
|
|
35
|
+
* @param {number} index Fallback row index.
|
|
36
|
+
* @returns {object}
|
|
37
|
+
*/
|
|
38
|
+
static #normalize(fields, index) {
|
|
39
|
+
const outline = AltiumLayoutParser.parseBoardOutline(fields)
|
|
40
|
+
return PcbPolygonRecordParser.#stripEmpty({
|
|
41
|
+
layer: getField(fields, 'LAYER') || 'UNKNOWN',
|
|
42
|
+
polygonIndex: PcbPolygonRecordParser.#firstNumber(fields, [
|
|
43
|
+
'POLYGONINDEX',
|
|
44
|
+
'POLYGON_INDEX',
|
|
45
|
+
'INDEX'
|
|
46
|
+
]),
|
|
47
|
+
subpolygonIndex: PcbPolygonRecordParser.#firstNumber(fields, [
|
|
48
|
+
'SUBPOLYINDEX',
|
|
49
|
+
'SUBPOLYGONINDEX',
|
|
50
|
+
'SUB_POLYGON_INDEX'
|
|
51
|
+
]),
|
|
52
|
+
unionIndex: PcbPolygonRecordParser.#firstNumber(fields, [
|
|
53
|
+
'UNIONINDEX',
|
|
54
|
+
'UNION_INDEX'
|
|
55
|
+
]),
|
|
56
|
+
isCutout: PcbPolygonRecordParser.#firstBoolean(fields, [
|
|
57
|
+
'ISCUTOUT',
|
|
58
|
+
'IS_CUTOUT',
|
|
59
|
+
'CUTOUT'
|
|
60
|
+
]),
|
|
61
|
+
realizationKind:
|
|
62
|
+
getField(fields, 'POLYGONKIND') ||
|
|
63
|
+
getField(fields, 'POURKIND') ||
|
|
64
|
+
'',
|
|
65
|
+
sourceRecordIndex: index,
|
|
66
|
+
segments: outline.segments
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reads the first numeric field from a list of native aliases.
|
|
72
|
+
* @param {Record<string, string | string[]>} fields Record fields.
|
|
73
|
+
* @param {string[]} keys Candidate keys.
|
|
74
|
+
* @returns {number | undefined}
|
|
75
|
+
*/
|
|
76
|
+
static #firstNumber(fields, keys) {
|
|
77
|
+
for (const key of keys) {
|
|
78
|
+
const value = parseNumericField(fields, key)
|
|
79
|
+
if (value !== null) {
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reads the first boolean field from a list of native aliases.
|
|
89
|
+
* @param {Record<string, string | string[]>} fields Record fields.
|
|
90
|
+
* @param {string[]} keys Candidate keys.
|
|
91
|
+
* @returns {boolean | undefined}
|
|
92
|
+
*/
|
|
93
|
+
static #firstBoolean(fields, keys) {
|
|
94
|
+
for (const key of keys) {
|
|
95
|
+
if (getField(fields, key)) {
|
|
96
|
+
return parseBoolean(fields[key])
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Removes empty optional fields while preserving zero and false.
|
|
105
|
+
* @param {Record<string, unknown>} value Candidate object.
|
|
106
|
+
* @returns {Record<string, unknown>}
|
|
107
|
+
*/
|
|
108
|
+
static #stripEmpty(value) {
|
|
109
|
+
return Object.fromEntries(
|
|
110
|
+
Object.entries(value || {}).filter(([, entryValue]) => {
|
|
111
|
+
if (Array.isArray(entryValue)) return entryValue.length > 0
|
|
112
|
+
return (
|
|
113
|
+
entryValue !== null &&
|
|
114
|
+
entryValue !== undefined &&
|
|
115
|
+
entryValue !== ''
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|