altium-toolkit 1.0.9 → 1.0.10
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/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
- package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
- package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +160 -1
- package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
- package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -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/svg_model_cross_link_a1.schema.json +39 -0
- package/package.json +1 -1
- package/src/core/altium/AltiumParser.mjs +7 -2
- package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
- package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
- package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
- package/src/core/altium/PcbModelParser.mjs +29 -4
- package/src/core/altium/PcbPadStackParser.mjs +171 -2
- package/src/core/altium/PcbPickPlacePositionResolver.mjs +8 -1
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
- package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
- package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
- package/src/core/altium/PrjPcbModelParser.mjs +24 -2
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
- package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
- package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
- package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
- package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
- package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/parser.mjs +6 -0
- package/src/ui/PcbSvgRenderer.mjs +65 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { AltiumParser } from './AltiumParser.mjs'
|
|
6
|
+
import { DraftsmanDigestParser } from './DraftsmanDigestParser.mjs'
|
|
7
|
+
import { PcbModelParser } from './PcbModelParser.mjs'
|
|
8
|
+
import { PrjPcbModelParser } from './PrjPcbModelParser.mjs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Runs deterministic synthetic compatibility cases against parser entrypoints.
|
|
12
|
+
*/
|
|
13
|
+
export class ParserCompatibilityFuzzer {
|
|
14
|
+
static SCHEMA = 'altium-toolkit.parser-compatibility-fuzz.a1'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs all built-in synthetic parser compatibility cases.
|
|
18
|
+
* @returns {object}
|
|
19
|
+
*/
|
|
20
|
+
static run() {
|
|
21
|
+
const cases = ParserCompatibilityFuzzer.#cases().map((entry) =>
|
|
22
|
+
ParserCompatibilityFuzzer.#runCase(entry)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
schema: ParserCompatibilityFuzzer.SCHEMA,
|
|
27
|
+
summary: {
|
|
28
|
+
caseCount: cases.length,
|
|
29
|
+
failureCount: cases.filter((entry) => entry.status === 'fail')
|
|
30
|
+
.length,
|
|
31
|
+
diagnosticCount: cases.reduce(
|
|
32
|
+
(total, entry) =>
|
|
33
|
+
total + Number(entry.diagnosticCount || 0),
|
|
34
|
+
0
|
|
35
|
+
)
|
|
36
|
+
},
|
|
37
|
+
cases
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lists deterministic compatibility cases.
|
|
43
|
+
* @returns {{ key: string, parse: () => object }[]}
|
|
44
|
+
*/
|
|
45
|
+
static #cases() {
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
key: 'sch-record-ordering',
|
|
49
|
+
parse: () =>
|
|
50
|
+
AltiumParser.parseArrayBufferToRendererModel(
|
|
51
|
+
'fuzz-order.SchDoc',
|
|
52
|
+
ParserCompatibilityFuzzer.#encodeText(
|
|
53
|
+
'|RECORD=999|Text=Unknown First|' +
|
|
54
|
+
'|HEADER=Schematic Document|' +
|
|
55
|
+
'|RECORD=31|CustomX=120|CustomY=80|BorderOn=F|TitleBlockOn=F|' +
|
|
56
|
+
'|RECORD=13|Location.X=10|Location.Y=10|Corner.X=80|Corner.Y=10|LineWidth=1|'
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: 'sch-odd-encoding',
|
|
62
|
+
parse: () =>
|
|
63
|
+
AltiumParser.parseArrayBufferToRendererModel(
|
|
64
|
+
'fuzz-encoding.SchDoc',
|
|
65
|
+
ParserCompatibilityFuzzer.#windows1252Schematic()
|
|
66
|
+
)
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'pcb-malformed-sidecars',
|
|
70
|
+
parse: () =>
|
|
71
|
+
PcbModelParser.parse('fuzz-sidecar.PcbDoc', [
|
|
72
|
+
{
|
|
73
|
+
sourceStream: 'Pads6/Data',
|
|
74
|
+
fields: {
|
|
75
|
+
X: 'not-a-number',
|
|
76
|
+
Y: '20mil',
|
|
77
|
+
HOLESIZE: 'malformed',
|
|
78
|
+
NET: 'NET_A'
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
sourceStream: 'ExtendedPrimitiveInformation/Data',
|
|
83
|
+
fields: {
|
|
84
|
+
PRIMITIVEINDEX: 'not-a-number',
|
|
85
|
+
SolderMaskExpansionMode: 'Manual',
|
|
86
|
+
SolderMaskExpansion: 'bad'
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
sourceStream: 'UnsupportedSidecar/Data',
|
|
91
|
+
fields: { RECORD: '777', VALUE: 'preserve' }
|
|
92
|
+
}
|
|
93
|
+
])
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: 'project-sparse-documents',
|
|
97
|
+
parse: () =>
|
|
98
|
+
PrjPcbModelParser.parseText(
|
|
99
|
+
'fuzz-project.PrjPcb',
|
|
100
|
+
'[Design]\n\n[Document1]\nDocumentUniqueId=EMPTY\n'
|
|
101
|
+
)
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: 'draftsman-unsupported-container',
|
|
105
|
+
parse: () =>
|
|
106
|
+
DraftsmanDigestParser.parse(
|
|
107
|
+
'fuzz.PCBDwf',
|
|
108
|
+
new Uint8Array([0, 1, 2, 3]).buffer
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Executes one compatibility case.
|
|
116
|
+
* @param {{ key: string, parse: () => object }} entry Case descriptor.
|
|
117
|
+
* @returns {object}
|
|
118
|
+
*/
|
|
119
|
+
static #runCase(entry) {
|
|
120
|
+
try {
|
|
121
|
+
const model = entry.parse()
|
|
122
|
+
return {
|
|
123
|
+
key: entry.key,
|
|
124
|
+
status: 'pass',
|
|
125
|
+
kind: model?.kind || '',
|
|
126
|
+
fileType: model?.fileType || '',
|
|
127
|
+
diagnosticCount: (model?.diagnostics || []).length,
|
|
128
|
+
summary: ParserCompatibilityFuzzer.#stableSummary(
|
|
129
|
+
model?.summary || {}
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
key: entry.key,
|
|
135
|
+
status: 'fail',
|
|
136
|
+
diagnosticCount: 1,
|
|
137
|
+
error: {
|
|
138
|
+
name: error?.name || 'Error',
|
|
139
|
+
message: error?.message || String(error)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Builds a stable compact summary object.
|
|
147
|
+
* @param {object} summary Parser summary.
|
|
148
|
+
* @returns {object}
|
|
149
|
+
*/
|
|
150
|
+
static #stableSummary(summary) {
|
|
151
|
+
return Object.fromEntries(
|
|
152
|
+
Object.entries(summary || {}).filter(([, value]) =>
|
|
153
|
+
['number', 'string', 'boolean'].includes(typeof value)
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Encodes text as UTF-8.
|
|
160
|
+
* @param {string} text Text payload.
|
|
161
|
+
* @returns {ArrayBuffer}
|
|
162
|
+
*/
|
|
163
|
+
static #encodeText(text) {
|
|
164
|
+
const bytes = new TextEncoder().encode(text)
|
|
165
|
+
return bytes.buffer.slice(
|
|
166
|
+
bytes.byteOffset,
|
|
167
|
+
bytes.byteOffset + bytes.length
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Builds a schematic payload with one Windows-1252 punctuation byte.
|
|
173
|
+
* @returns {ArrayBuffer}
|
|
174
|
+
*/
|
|
175
|
+
static #windows1252Schematic() {
|
|
176
|
+
const prefix = new TextEncoder().encode(
|
|
177
|
+
'|HEADER=Schematic Document|' +
|
|
178
|
+
'|RECORD=31|CustomX=120|CustomY=80|BorderOn=F|TitleBlockOn=F|' +
|
|
179
|
+
'|RECORD=4|Location.X=20|Location.Y=20|TEXT=ESD'
|
|
180
|
+
)
|
|
181
|
+
const suffix = new TextEncoder().encode('TVS|')
|
|
182
|
+
const bytes = new Uint8Array(prefix.length + 1 + suffix.length)
|
|
183
|
+
bytes.set(prefix, 0)
|
|
184
|
+
bytes[prefix.length] = 0x96
|
|
185
|
+
bytes.set(suffix, prefix.length + 1)
|
|
186
|
+
|
|
187
|
+
return bytes.buffer.slice(
|
|
188
|
+
bytes.byteOffset,
|
|
189
|
+
bytes.byteOffset + bytes.length
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -16,6 +16,7 @@ import { PcbMaskPasteResolver } from './PcbMaskPasteResolver.mjs'
|
|
|
16
16
|
import { PcbOutlineRecovery } from './PcbOutlineRecovery.mjs'
|
|
17
17
|
import { PcbOwnershipGraphBuilder } from './PcbOwnershipGraphBuilder.mjs'
|
|
18
18
|
import { PcbPickPlacePositionResolver } from './PcbPickPlacePositionResolver.mjs'
|
|
19
|
+
import { PcbRouteAnalysisBuilder } from './PcbRouteAnalysisBuilder.mjs'
|
|
19
20
|
import { PcbRuleParser } from './PcbRuleParser.mjs'
|
|
20
21
|
import { PcbSpecialStringResolver } from './PcbSpecialStringResolver.mjs'
|
|
21
22
|
import { PcbStatisticsBuilder } from './PcbStatisticsBuilder.mjs'
|
|
@@ -281,6 +282,16 @@ export class PcbModelParser {
|
|
|
281
282
|
componentPrimitiveGroups,
|
|
282
283
|
{ sourceComponents: componentRecords }
|
|
283
284
|
)
|
|
285
|
+
const routeAnalysis = PcbRouteAnalysisBuilder.build({
|
|
286
|
+
...normalizedPcb,
|
|
287
|
+
layers,
|
|
288
|
+
primitiveLayers,
|
|
289
|
+
nets,
|
|
290
|
+
classes,
|
|
291
|
+
differentialPairs: differentialPairData.differentialPairs,
|
|
292
|
+
differentialPairClasses:
|
|
293
|
+
differentialPairData.differentialPairClasses
|
|
294
|
+
})
|
|
284
295
|
const statistics = PcbStatisticsBuilder.build({
|
|
285
296
|
...normalizedPcb,
|
|
286
297
|
layers,
|
|
@@ -556,6 +567,8 @@ export class PcbModelParser {
|
|
|
556
567
|
customPadShapeCount: customPadShapes.entries?.length || 0,
|
|
557
568
|
userUnionCount: unions.userUnions?.length || 0,
|
|
558
569
|
smartUnionCount: unions.smartUnions?.length || 0,
|
|
570
|
+
routedNetCount: routeAnalysis.summary.routedNetCount,
|
|
571
|
+
routedLengthMil: routeAnalysis.summary.totalLengthMil,
|
|
559
572
|
boardRegionCount: boardRegionSummary.boardRegionCount,
|
|
560
573
|
flexRegionCount: boardRegionSummary.flexRegionCount,
|
|
561
574
|
bendingLineCount: boardRegionSummary.bendingLineCount,
|
|
@@ -588,6 +601,7 @@ export class PcbModelParser {
|
|
|
588
601
|
dimensions,
|
|
589
602
|
components: normalizedPcb.components,
|
|
590
603
|
pickPlace: pnp,
|
|
604
|
+
routeAnalysis,
|
|
591
605
|
polygons: normalizedPcb.polygons,
|
|
592
606
|
fills: normalizedPcb.fills,
|
|
593
607
|
tracks: normalizedPcb.tracks,
|
|
@@ -1193,25 +1207,36 @@ export class PcbModelParser {
|
|
|
1193
1207
|
key === 'MEMBERCOUNT' ||
|
|
1194
1208
|
key === 'ENABLED' ||
|
|
1195
1209
|
key === 'UNIQUEID' ||
|
|
1196
|
-
/^M\d+$/.test(key)
|
|
1210
|
+
/^(?:M|MEMBER)\d+$/.test(key)
|
|
1197
1211
|
)
|
|
1198
1212
|
}
|
|
1199
1213
|
|
|
1200
1214
|
/**
|
|
1201
|
-
* Extracts ordered class members from M0
|
|
1215
|
+
* Extracts ordered class members from M0/MEMBER0-style fields.
|
|
1202
1216
|
* @param {Record<string, string | string[]>} fields
|
|
1203
1217
|
* @returns {string[]}
|
|
1204
1218
|
*/
|
|
1205
1219
|
static #parseClassMembers(fields) {
|
|
1206
1220
|
return Object.keys(fields || {})
|
|
1207
|
-
.filter((key) => /^M\d+$/.test(key))
|
|
1221
|
+
.filter((key) => /^(?:M|MEMBER)\d+$/.test(key))
|
|
1208
1222
|
.sort(
|
|
1209
|
-
(left, right) =>
|
|
1223
|
+
(left, right) =>
|
|
1224
|
+
PcbModelParser.#classMemberIndex(left) -
|
|
1225
|
+
PcbModelParser.#classMemberIndex(right)
|
|
1210
1226
|
)
|
|
1211
1227
|
.map((key) => getField(fields, key))
|
|
1212
1228
|
.filter(Boolean)
|
|
1213
1229
|
}
|
|
1214
1230
|
|
|
1231
|
+
/**
|
|
1232
|
+
* Extracts the numeric index from a class member field name.
|
|
1233
|
+
* @param {string} key Field key.
|
|
1234
|
+
* @returns {number}
|
|
1235
|
+
*/
|
|
1236
|
+
static #classMemberIndex(key) {
|
|
1237
|
+
return Number(String(key).replace(/^(?:M|MEMBER)/u, ''))
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1215
1240
|
/**
|
|
1216
1241
|
* Returns a stable display name for one native PCB class kind.
|
|
1217
1242
|
* @param {number} kind
|
|
@@ -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,
|
|
@@ -165,17 +165,26 @@ export class PcbRegionPrimitiveParser {
|
|
|
165
165
|
net: 3,
|
|
166
166
|
polygon: 5
|
|
167
167
|
})
|
|
168
|
+
const kind = PcbRegionPrimitiveParser.#numericKind(properties.KIND)
|
|
169
|
+
const legacyCutout =
|
|
170
|
+
PcbRegionPrimitiveParser.#legacyCutoutClassification(
|
|
171
|
+
properties,
|
|
172
|
+
kind
|
|
173
|
+
)
|
|
168
174
|
|
|
169
175
|
return {
|
|
170
176
|
region: {
|
|
171
177
|
layerId,
|
|
172
178
|
layerCode: layerId,
|
|
173
179
|
...ownershipIndexes,
|
|
174
|
-
kind
|
|
180
|
+
kind,
|
|
181
|
+
...legacyCutout.fields,
|
|
175
182
|
isKeepout: flags2 === 2,
|
|
176
183
|
isBoardCutout:
|
|
184
|
+
legacyCutout.isBoardCutout ||
|
|
177
185
|
String(properties.ISBOARDCUTOUT || '').toUpperCase() ===
|
|
178
|
-
|
|
186
|
+
'TRUE',
|
|
187
|
+
...legacyCutout.cutoutFlags,
|
|
179
188
|
isShapeBased:
|
|
180
189
|
shapeBased ||
|
|
181
190
|
String(properties.ISSHAPEBASED || '').toUpperCase() ===
|
|
@@ -188,6 +197,66 @@ export class PcbRegionPrimitiveParser {
|
|
|
188
197
|
}
|
|
189
198
|
}
|
|
190
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Parses a region kind while avoiding NaN for legacy symbolic labels.
|
|
202
|
+
* @param {string | undefined} rawKind Raw KIND value.
|
|
203
|
+
* @returns {number | null}
|
|
204
|
+
*/
|
|
205
|
+
static #numericKind(rawKind) {
|
|
206
|
+
if (rawKind === undefined || rawKind === null || rawKind === '') {
|
|
207
|
+
return 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const kind = Number(rawKind)
|
|
211
|
+
return Number.isFinite(kind) ? kind : null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Builds cutout fields from legacy string KIND labels.
|
|
216
|
+
* @param {Record<string, string>} properties Native property map.
|
|
217
|
+
* @param {number | null} numericKind Parsed numeric kind.
|
|
218
|
+
* @returns {{ isBoardCutout: boolean, fields: object, cutoutFlags: object }}
|
|
219
|
+
*/
|
|
220
|
+
static #legacyCutoutClassification(properties, numericKind) {
|
|
221
|
+
const rawKind = String(properties.KIND || '').trim()
|
|
222
|
+
if (numericKind !== null || !rawKind) {
|
|
223
|
+
return {
|
|
224
|
+
isBoardCutout: false,
|
|
225
|
+
fields: {},
|
|
226
|
+
cutoutFlags: {}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const normalized = rawKind.replace(/[^a-z0-9]/giu, '').toLowerCase()
|
|
231
|
+
const isBoardCutout = normalized === 'boardcutout'
|
|
232
|
+
const isPolygonPourCutout =
|
|
233
|
+
normalized === 'polygonpourcutout' ||
|
|
234
|
+
normalized === 'polygoncutout' ||
|
|
235
|
+
normalized === 'pourcutout'
|
|
236
|
+
const classification =
|
|
237
|
+
isBoardCutout || isPolygonPourCutout
|
|
238
|
+
? {
|
|
239
|
+
isBoardCutout,
|
|
240
|
+
isPolygonPourCutout,
|
|
241
|
+
source: 'legacy-kind',
|
|
242
|
+
rawKind
|
|
243
|
+
}
|
|
244
|
+
: null
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
isBoardCutout,
|
|
248
|
+
fields: {
|
|
249
|
+
rawKind
|
|
250
|
+
},
|
|
251
|
+
cutoutFlags: classification
|
|
252
|
+
? {
|
|
253
|
+
isPolygonPourCutout,
|
|
254
|
+
cutoutClassification: classification
|
|
255
|
+
}
|
|
256
|
+
: {}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
191
260
|
/**
|
|
192
261
|
* Reads one simple double-coordinate region vertex list.
|
|
193
262
|
* @param {DataView} view
|