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
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { PcbReviewDrillMetadataBuilder } from './PcbReviewDrillMetadataBuilder.mjs'
|
|
6
|
+
import { PcbReviewPolygonRealizationBuilder } from './PcbReviewPolygonRealizationBuilder.mjs'
|
|
7
|
+
import { PcbReviewRouteHighlightProfileBuilder } from './PcbReviewRouteHighlightProfileBuilder.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds PCB review metadata for routed-class and board-assembly workflows.
|
|
11
|
+
*/
|
|
12
|
+
export class PcbReviewMetadataBuilder {
|
|
13
|
+
static SCHEMA = 'altium-toolkit.pcb.review-metadata.a1'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Builds a normalized review-metadata sidecar.
|
|
17
|
+
* @param {{ routeAnalysis?: object, embeddedModels?: object[], componentBodies?: object[], layers?: object[], primitiveLayers?: object[], polygons?: object[], tracks?: object[], arcs?: object[], fills?: object[], vias?: object[], pads?: object[], regions?: object[], shapeBasedRegions?: object[] }} pcb Review context.
|
|
18
|
+
* @returns {object}
|
|
19
|
+
*/
|
|
20
|
+
static build(pcb = {}) {
|
|
21
|
+
const routeGroups = PcbReviewMetadataBuilder.#routeGroups(
|
|
22
|
+
pcb.routeAnalysis || {}
|
|
23
|
+
)
|
|
24
|
+
const routeHighlightProfiles =
|
|
25
|
+
PcbReviewRouteHighlightProfileBuilder.build(pcb.routeAnalysis || {})
|
|
26
|
+
const polygonRealizations =
|
|
27
|
+
PcbReviewPolygonRealizationBuilder.build(pcb)
|
|
28
|
+
const drillReview = PcbReviewDrillMetadataBuilder.build(pcb)
|
|
29
|
+
const boardAssemblyViews = PcbReviewMetadataBuilder.#boardAssemblyViews(
|
|
30
|
+
pcb.embeddedModels || [],
|
|
31
|
+
pcb.componentBodies || []
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
schema: PcbReviewMetadataBuilder.SCHEMA,
|
|
36
|
+
summary: {
|
|
37
|
+
routeGroupCount: routeGroups.length,
|
|
38
|
+
boardAssemblyViewCount: boardAssemblyViews.length,
|
|
39
|
+
polygonRealizationCount: polygonRealizations.length,
|
|
40
|
+
routeHighlightProfileCount: routeHighlightProfiles.length,
|
|
41
|
+
drillOverlayCount: drillReview.overlays.length
|
|
42
|
+
},
|
|
43
|
+
routeGroups,
|
|
44
|
+
routeHighlightProfiles,
|
|
45
|
+
polygonRealizations,
|
|
46
|
+
drillReview,
|
|
47
|
+
boardAssemblyViews,
|
|
48
|
+
indexes: PcbReviewMetadataBuilder.#indexes(
|
|
49
|
+
routeGroups,
|
|
50
|
+
routeHighlightProfiles,
|
|
51
|
+
polygonRealizations,
|
|
52
|
+
drillReview,
|
|
53
|
+
boardAssemblyViews,
|
|
54
|
+
pcb.routeAnalysis || {}
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Builds route highlight groups from route analysis.
|
|
61
|
+
* @param {object} routeAnalysis Route analysis model.
|
|
62
|
+
* @returns {object[]}
|
|
63
|
+
*/
|
|
64
|
+
static #routeGroups(routeAnalysis) {
|
|
65
|
+
return [
|
|
66
|
+
...PcbReviewMetadataBuilder.#classGroups(routeAnalysis),
|
|
67
|
+
...PcbReviewMetadataBuilder.#differentialPairGroups(routeAnalysis)
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Builds net-class route groups.
|
|
73
|
+
* @param {object} routeAnalysis Route analysis model.
|
|
74
|
+
* @returns {object[]}
|
|
75
|
+
*/
|
|
76
|
+
static #classGroups(routeAnalysis) {
|
|
77
|
+
return (routeAnalysis.classes || []).map((classRow) =>
|
|
78
|
+
PcbReviewMetadataBuilder.#stripEmpty({
|
|
79
|
+
key:
|
|
80
|
+
'route-class-' +
|
|
81
|
+
PcbReviewMetadataBuilder.#slug(classRow.name),
|
|
82
|
+
kind: 'net-class',
|
|
83
|
+
name: classRow.name,
|
|
84
|
+
netNames: classRow.netNames || [],
|
|
85
|
+
layerKeys: PcbReviewMetadataBuilder.#layerKeysForNets(
|
|
86
|
+
routeAnalysis,
|
|
87
|
+
classRow.netNames || []
|
|
88
|
+
),
|
|
89
|
+
primitiveKeys: PcbReviewMetadataBuilder.#primitiveKeysForNets(
|
|
90
|
+
routeAnalysis,
|
|
91
|
+
classRow.netNames || []
|
|
92
|
+
),
|
|
93
|
+
totalLengthMil: classRow.totalLengthMil
|
|
94
|
+
})
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Builds differential-pair route groups.
|
|
100
|
+
* @param {object} routeAnalysis Route analysis model.
|
|
101
|
+
* @returns {object[]}
|
|
102
|
+
*/
|
|
103
|
+
static #differentialPairGroups(routeAnalysis) {
|
|
104
|
+
return (routeAnalysis.differentialPairs || []).map((pair) => {
|
|
105
|
+
const netNames = [
|
|
106
|
+
pair.positiveNetName,
|
|
107
|
+
pair.negativeNetName
|
|
108
|
+
].filter(Boolean)
|
|
109
|
+
|
|
110
|
+
return PcbReviewMetadataBuilder.#stripEmpty({
|
|
111
|
+
key:
|
|
112
|
+
'route-diff-pair-' +
|
|
113
|
+
PcbReviewMetadataBuilder.#slug(pair.name),
|
|
114
|
+
kind: 'differential-pair',
|
|
115
|
+
name: pair.name,
|
|
116
|
+
netNames,
|
|
117
|
+
layerKeys: PcbReviewMetadataBuilder.#layerKeysForNets(
|
|
118
|
+
routeAnalysis,
|
|
119
|
+
netNames
|
|
120
|
+
),
|
|
121
|
+
primitiveKeys: PcbReviewMetadataBuilder.#primitiveKeysForNets(
|
|
122
|
+
routeAnalysis,
|
|
123
|
+
netNames
|
|
124
|
+
),
|
|
125
|
+
totalLengthMil: PcbReviewMetadataBuilder.#round(
|
|
126
|
+
Number(pair.positiveLengthMil || 0) +
|
|
127
|
+
Number(pair.negativeLengthMil || 0)
|
|
128
|
+
),
|
|
129
|
+
skewLengthMil: pair.skewLengthMil,
|
|
130
|
+
classes: pair.classes || []
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Builds board assembly view candidates from unreferenced model payloads.
|
|
137
|
+
* @param {object[]} embeddedModels Embedded model payload rows.
|
|
138
|
+
* @param {object[]} componentBodies Component body rows.
|
|
139
|
+
* @returns {object[]}
|
|
140
|
+
*/
|
|
141
|
+
static #boardAssemblyViews(embeddedModels, componentBodies) {
|
|
142
|
+
const referencedModelKeys =
|
|
143
|
+
PcbReviewMetadataBuilder.#referencedModelKeys(componentBodies)
|
|
144
|
+
|
|
145
|
+
return (embeddedModels || [])
|
|
146
|
+
.filter((model) => {
|
|
147
|
+
const keys = PcbReviewMetadataBuilder.#modelKeys(model)
|
|
148
|
+
return !keys.some((key) => referencedModelKeys.has(key))
|
|
149
|
+
})
|
|
150
|
+
.map((model, index) =>
|
|
151
|
+
PcbReviewMetadataBuilder.#stripEmpty({
|
|
152
|
+
key:
|
|
153
|
+
'board-assembly-' +
|
|
154
|
+
index +
|
|
155
|
+
'-' +
|
|
156
|
+
PcbReviewMetadataBuilder.#slug(model.name || index),
|
|
157
|
+
name: model.name,
|
|
158
|
+
format: model.format,
|
|
159
|
+
sourceStream: model.sourceStream,
|
|
160
|
+
modelId: model.id,
|
|
161
|
+
reason: 'embedded model is not referenced by component bodies'
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Builds review lookup indexes.
|
|
168
|
+
* @param {object[]} routeGroups Route review groups.
|
|
169
|
+
* @param {object[]} routeHighlightProfiles Route-highlight profiles.
|
|
170
|
+
* @param {object[]} polygonRealizations Polygon realization rows.
|
|
171
|
+
* @param {{ overlays: object[] }} drillReview Drill review rows.
|
|
172
|
+
* @param {object[]} boardAssemblyViews Assembly-view candidates.
|
|
173
|
+
* @param {object} routeAnalysis Route analysis model.
|
|
174
|
+
* @returns {object}
|
|
175
|
+
*/
|
|
176
|
+
static #indexes(
|
|
177
|
+
routeGroups,
|
|
178
|
+
routeHighlightProfiles,
|
|
179
|
+
polygonRealizations,
|
|
180
|
+
drillReview,
|
|
181
|
+
boardAssemblyViews,
|
|
182
|
+
routeAnalysis
|
|
183
|
+
) {
|
|
184
|
+
const routeGroupsByName = {}
|
|
185
|
+
const routeHighlightProfilesByName = {}
|
|
186
|
+
const polygonRealizationsByKey = {}
|
|
187
|
+
const drillOverlaysByOwnerKey = {}
|
|
188
|
+
const boardAssemblyViewsByName = {}
|
|
189
|
+
|
|
190
|
+
routeGroups.forEach((group, index) => {
|
|
191
|
+
if (group.name) routeGroupsByName[group.name] = index
|
|
192
|
+
})
|
|
193
|
+
routeHighlightProfiles.forEach((profile, index) => {
|
|
194
|
+
if (profile.name) routeHighlightProfilesByName[profile.name] = index
|
|
195
|
+
})
|
|
196
|
+
polygonRealizations.forEach((realization, index) => {
|
|
197
|
+
polygonRealizationsByKey[realization.key] = index
|
|
198
|
+
})
|
|
199
|
+
for (const [index, overlay] of (drillReview.overlays || []).entries()) {
|
|
200
|
+
drillOverlaysByOwnerKey[overlay.ownerKey] = index
|
|
201
|
+
}
|
|
202
|
+
boardAssemblyViews.forEach((view, index) => {
|
|
203
|
+
if (view.name) boardAssemblyViewsByName[view.name] = index
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
routeGroupsByName,
|
|
208
|
+
routeHighlightProfilesByName,
|
|
209
|
+
primitiveKeysByNet:
|
|
210
|
+
PcbReviewMetadataBuilder.#primitiveKeysByNet(routeAnalysis),
|
|
211
|
+
polygonRealizationsByKey,
|
|
212
|
+
drillOverlaysByOwnerKey,
|
|
213
|
+
boardAssemblyViewsByName
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Builds primitive-key lookups by net.
|
|
219
|
+
* @param {object} routeAnalysis Route analysis model.
|
|
220
|
+
* @returns {Record<string, string[]>}
|
|
221
|
+
*/
|
|
222
|
+
static #primitiveKeysByNet(routeAnalysis) {
|
|
223
|
+
const entries = {}
|
|
224
|
+
for (const net of routeAnalysis.byNet || []) {
|
|
225
|
+
entries[net.netName] = PcbReviewMetadataBuilder.#primitiveKeys(net)
|
|
226
|
+
}
|
|
227
|
+
return Object.fromEntries(
|
|
228
|
+
Object.entries(entries).sort(([left], [right]) =>
|
|
229
|
+
left.localeCompare(right, undefined, { numeric: true })
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Returns layer keys participating in a list of nets.
|
|
236
|
+
* @param {object} routeAnalysis Route analysis model.
|
|
237
|
+
* @param {string[]} netNames Net names.
|
|
238
|
+
* @returns {string[]}
|
|
239
|
+
*/
|
|
240
|
+
static #layerKeysForNets(routeAnalysis, netNames) {
|
|
241
|
+
const nets = PcbReviewMetadataBuilder.#netsByName(
|
|
242
|
+
routeAnalysis,
|
|
243
|
+
netNames
|
|
244
|
+
)
|
|
245
|
+
return PcbReviewMetadataBuilder.#sortedStrings(
|
|
246
|
+
nets.flatMap((net) => net.layers || [])
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Returns primitive keys participating in a list of nets.
|
|
252
|
+
* @param {object} routeAnalysis Route analysis model.
|
|
253
|
+
* @param {string[]} netNames Net names.
|
|
254
|
+
* @returns {string[]}
|
|
255
|
+
*/
|
|
256
|
+
static #primitiveKeysForNets(routeAnalysis, netNames) {
|
|
257
|
+
const nets = PcbReviewMetadataBuilder.#netsByName(
|
|
258
|
+
routeAnalysis,
|
|
259
|
+
netNames
|
|
260
|
+
)
|
|
261
|
+
return PcbReviewMetadataBuilder.#sortedStrings(
|
|
262
|
+
nets.flatMap((net) => PcbReviewMetadataBuilder.#primitiveKeys(net))
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Resolves net route rows by name.
|
|
268
|
+
* @param {object} routeAnalysis Route analysis model.
|
|
269
|
+
* @param {string[]} netNames Net names.
|
|
270
|
+
* @returns {object[]}
|
|
271
|
+
*/
|
|
272
|
+
static #netsByName(routeAnalysis, netNames) {
|
|
273
|
+
const wanted = new Set(netNames || [])
|
|
274
|
+
return (routeAnalysis.byNet || []).filter((net) =>
|
|
275
|
+
wanted.has(net.netName)
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Collects primitive keys from one net route row.
|
|
281
|
+
* @param {object} net Net route row.
|
|
282
|
+
* @returns {string[]}
|
|
283
|
+
*/
|
|
284
|
+
static #primitiveKeys(net) {
|
|
285
|
+
return PcbReviewMetadataBuilder.#sortedStrings(
|
|
286
|
+
(net.connectedRouteGroups || []).flatMap(
|
|
287
|
+
(group) => group.primitiveKeys || []
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Collects model reference keys from component bodies.
|
|
294
|
+
* @param {object[]} componentBodies Component body rows.
|
|
295
|
+
* @returns {Set<string>}
|
|
296
|
+
*/
|
|
297
|
+
static #referencedModelKeys(componentBodies) {
|
|
298
|
+
const keys = new Set()
|
|
299
|
+
for (const componentBody of componentBodies || []) {
|
|
300
|
+
for (const key of PcbReviewMetadataBuilder.#modelKeys(
|
|
301
|
+
componentBody
|
|
302
|
+
)) {
|
|
303
|
+
keys.add(key)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return keys
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Builds comparable model identity keys.
|
|
311
|
+
* @param {object} value Model or body row.
|
|
312
|
+
* @returns {string[]}
|
|
313
|
+
*/
|
|
314
|
+
static #modelKeys(value) {
|
|
315
|
+
return [value?.id, value?.modelId, value?.checksum, value?.name]
|
|
316
|
+
.map((entry) => String(entry ?? '').trim())
|
|
317
|
+
.filter(Boolean)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Sorts and deduplicates strings naturally.
|
|
322
|
+
* @param {string[]} values Source values.
|
|
323
|
+
* @returns {string[]}
|
|
324
|
+
*/
|
|
325
|
+
static #sortedStrings(values) {
|
|
326
|
+
return [...new Set((values || []).filter(Boolean))].sort(
|
|
327
|
+
(left, right) =>
|
|
328
|
+
left.localeCompare(right, undefined, { numeric: true })
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Converts a value to a deterministic lowercase key segment.
|
|
334
|
+
* @param {unknown} value Source value.
|
|
335
|
+
* @returns {string}
|
|
336
|
+
*/
|
|
337
|
+
static #slug(value) {
|
|
338
|
+
return (
|
|
339
|
+
String(value || '')
|
|
340
|
+
.trim()
|
|
341
|
+
.toLowerCase()
|
|
342
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
343
|
+
.replace(/^-+|-+$/gu, '') || 'item'
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Rounds numeric values for stable JSON.
|
|
349
|
+
* @param {number} value Candidate number.
|
|
350
|
+
* @returns {number}
|
|
351
|
+
*/
|
|
352
|
+
static #round(value) {
|
|
353
|
+
return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : 0
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Removes empty fields while preserving zeros and false.
|
|
358
|
+
* @param {Record<string, unknown>} value Candidate object.
|
|
359
|
+
* @returns {Record<string, unknown>}
|
|
360
|
+
*/
|
|
361
|
+
static #stripEmpty(value) {
|
|
362
|
+
return Object.fromEntries(
|
|
363
|
+
Object.entries(value || {}).filter(([, entryValue]) => {
|
|
364
|
+
if (Array.isArray(entryValue)) return entryValue.length > 0
|
|
365
|
+
return (
|
|
366
|
+
entryValue !== null &&
|
|
367
|
+
entryValue !== undefined &&
|
|
368
|
+
entryValue !== ''
|
|
369
|
+
)
|
|
370
|
+
})
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds polygon-pour realization rows for PCB review metadata.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbReviewPolygonRealizationBuilder {
|
|
9
|
+
/**
|
|
10
|
+
* Builds polygon-pour realization sidecars.
|
|
11
|
+
* @param {object} pcb Normalized PCB model.
|
|
12
|
+
* @returns {object[]}
|
|
13
|
+
*/
|
|
14
|
+
static build(pcb = {}) {
|
|
15
|
+
const layerNameToKey =
|
|
16
|
+
PcbReviewPolygonRealizationBuilder.#layerNameToKey(pcb)
|
|
17
|
+
const rows = [
|
|
18
|
+
...PcbReviewPolygonRealizationBuilder.#realizationRows(
|
|
19
|
+
'polygon',
|
|
20
|
+
pcb.polygons || [],
|
|
21
|
+
layerNameToKey
|
|
22
|
+
),
|
|
23
|
+
...PcbReviewPolygonRealizationBuilder.#realizationRows(
|
|
24
|
+
'track',
|
|
25
|
+
pcb.tracks || [],
|
|
26
|
+
layerNameToKey
|
|
27
|
+
),
|
|
28
|
+
...PcbReviewPolygonRealizationBuilder.#realizationRows(
|
|
29
|
+
'arc',
|
|
30
|
+
pcb.arcs || [],
|
|
31
|
+
layerNameToKey
|
|
32
|
+
),
|
|
33
|
+
...PcbReviewPolygonRealizationBuilder.#realizationRows(
|
|
34
|
+
'fill',
|
|
35
|
+
pcb.fills || [],
|
|
36
|
+
layerNameToKey
|
|
37
|
+
),
|
|
38
|
+
...PcbReviewPolygonRealizationBuilder.#realizationRows(
|
|
39
|
+
'region',
|
|
40
|
+
pcb.regions || [],
|
|
41
|
+
layerNameToKey
|
|
42
|
+
),
|
|
43
|
+
...PcbReviewPolygonRealizationBuilder.#realizationRows(
|
|
44
|
+
'shape-based-region',
|
|
45
|
+
pcb.shapeBasedRegions || [],
|
|
46
|
+
layerNameToKey
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
const groups = new Map()
|
|
50
|
+
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
if (!Number.isFinite(row.polygonIndex)) {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
const key =
|
|
56
|
+
row.polygonIndex +
|
|
57
|
+
':' +
|
|
58
|
+
(row.subpolygonIndex ?? '') +
|
|
59
|
+
':' +
|
|
60
|
+
(row.unionIndex ?? '')
|
|
61
|
+
if (!groups.has(key)) {
|
|
62
|
+
groups.set(key, {
|
|
63
|
+
polygonIndex: row.polygonIndex,
|
|
64
|
+
subpolygonIndex: row.subpolygonIndex,
|
|
65
|
+
unionIndex: row.unionIndex,
|
|
66
|
+
isCutout: false,
|
|
67
|
+
layerKeys: new Set(),
|
|
68
|
+
primitiveKeys: new Set(),
|
|
69
|
+
realizedPrimitiveKinds: new Set()
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
const group = groups.get(key)
|
|
73
|
+
group.isCutout = group.isCutout || row.isCutout === true
|
|
74
|
+
if (row.layerKey) group.layerKeys.add(row.layerKey)
|
|
75
|
+
group.primitiveKeys.add(row.primitiveKey)
|
|
76
|
+
group.realizedPrimitiveKinds.add(row.kind)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return [...groups.values()]
|
|
80
|
+
.map((group) =>
|
|
81
|
+
PcbReviewPolygonRealizationBuilder.#stripEmpty({
|
|
82
|
+
key:
|
|
83
|
+
'polygon-realization-' +
|
|
84
|
+
group.polygonIndex +
|
|
85
|
+
'-' +
|
|
86
|
+
(group.subpolygonIndex ?? 'main') +
|
|
87
|
+
'-' +
|
|
88
|
+
(group.unionIndex ?? 'none'),
|
|
89
|
+
polygonIndex: group.polygonIndex,
|
|
90
|
+
subpolygonIndex: group.subpolygonIndex,
|
|
91
|
+
unionIndex: group.unionIndex,
|
|
92
|
+
classification: group.isCutout ? 'cutout' : 'copper-pour',
|
|
93
|
+
layerKeys:
|
|
94
|
+
PcbReviewPolygonRealizationBuilder.#sortedStrings([
|
|
95
|
+
...group.layerKeys
|
|
96
|
+
]),
|
|
97
|
+
primitiveKeys:
|
|
98
|
+
PcbReviewPolygonRealizationBuilder.#sortedStrings([
|
|
99
|
+
...group.primitiveKeys
|
|
100
|
+
]),
|
|
101
|
+
realizedPrimitiveKinds:
|
|
102
|
+
PcbReviewPolygonRealizationBuilder.#sortedStrings([
|
|
103
|
+
...group.realizedPrimitiveKinds
|
|
104
|
+
])
|
|
105
|
+
})
|
|
106
|
+
)
|
|
107
|
+
.sort((left, right) =>
|
|
108
|
+
left.key.localeCompare(right.key, undefined, { numeric: true })
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Builds realization rows for one primitive collection.
|
|
114
|
+
* @param {string} kind Primitive kind.
|
|
115
|
+
* @param {object[]} primitives Primitive rows.
|
|
116
|
+
* @param {Map<string, string>} layerNameToKey Layer-name lookup.
|
|
117
|
+
* @returns {object[]}
|
|
118
|
+
*/
|
|
119
|
+
static #realizationRows(kind, primitives, layerNameToKey) {
|
|
120
|
+
return (primitives || []).map((primitive, index) => ({
|
|
121
|
+
kind,
|
|
122
|
+
primitiveKey: kind + '-' + index,
|
|
123
|
+
polygonIndex: PcbReviewPolygonRealizationBuilder.#optionalNumber(
|
|
124
|
+
primitive?.polygonIndex
|
|
125
|
+
),
|
|
126
|
+
subpolygonIndex: PcbReviewPolygonRealizationBuilder.#optionalNumber(
|
|
127
|
+
primitive?.subpolygonIndex ?? primitive?.subpolyIndex
|
|
128
|
+
),
|
|
129
|
+
unionIndex: PcbReviewPolygonRealizationBuilder.#optionalNumber(
|
|
130
|
+
primitive?.unionIndex
|
|
131
|
+
),
|
|
132
|
+
isCutout:
|
|
133
|
+
primitive?.isCutout === true ||
|
|
134
|
+
primitive?.classification === 'cutout',
|
|
135
|
+
layerKey: PcbReviewPolygonRealizationBuilder.#layerKey(
|
|
136
|
+
primitive,
|
|
137
|
+
layerNameToKey
|
|
138
|
+
)
|
|
139
|
+
}))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Builds a layer-name to layer-key lookup.
|
|
144
|
+
* @param {object} pcb PCB model.
|
|
145
|
+
* @returns {Map<string, string>}
|
|
146
|
+
*/
|
|
147
|
+
static #layerNameToKey(pcb) {
|
|
148
|
+
const lookup = new Map()
|
|
149
|
+
for (const layer of [
|
|
150
|
+
...(pcb.layers || []),
|
|
151
|
+
...(pcb.primitiveLayers || [])
|
|
152
|
+
]) {
|
|
153
|
+
const layerId = PcbReviewPolygonRealizationBuilder.#layerId(layer)
|
|
154
|
+
const name = String(layer?.displayName || layer?.name || '').trim()
|
|
155
|
+
if (Number.isInteger(layerId) && name) {
|
|
156
|
+
lookup.set(
|
|
157
|
+
PcbReviewPolygonRealizationBuilder.#lookupName(name),
|
|
158
|
+
'L' + layerId
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return lookup
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolves a layer key from a primitive or layer descriptor.
|
|
167
|
+
* @param {object} value Primitive or layer descriptor.
|
|
168
|
+
* @param {Map<string, string>} [layerNameToKey] Optional layer-name lookup.
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
static #layerKey(value, layerNameToKey = new Map()) {
|
|
172
|
+
const layerId = PcbReviewPolygonRealizationBuilder.#layerId(value)
|
|
173
|
+
if (Number.isInteger(layerId)) {
|
|
174
|
+
return 'L' + layerId
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const layer = String(value?.layer || value?.layerName || '').trim()
|
|
178
|
+
const lookupKey = PcbReviewPolygonRealizationBuilder.#lookupName(layer)
|
|
179
|
+
if (layerNameToKey.has(lookupKey)) {
|
|
180
|
+
return layerNameToKey.get(lookupKey)
|
|
181
|
+
}
|
|
182
|
+
return layer
|
|
183
|
+
? 'L-' + PcbReviewPolygonRealizationBuilder.#slug(layer)
|
|
184
|
+
: ''
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolves a numeric layer id.
|
|
189
|
+
* @param {object} value Primitive or layer descriptor.
|
|
190
|
+
* @returns {number | undefined}
|
|
191
|
+
*/
|
|
192
|
+
static #layerId(value) {
|
|
193
|
+
for (const key of ['layerId', 'layerCode', 'id', 'index']) {
|
|
194
|
+
const layerId = Number(value?.[key])
|
|
195
|
+
if (Number.isInteger(layerId)) {
|
|
196
|
+
return layerId
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Returns a finite number or undefined.
|
|
205
|
+
* @param {unknown} value Candidate value.
|
|
206
|
+
* @returns {number | undefined}
|
|
207
|
+
*/
|
|
208
|
+
static #optionalNumber(value) {
|
|
209
|
+
const number = Number(value)
|
|
210
|
+
return Number.isFinite(number) ? number : undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Sorts and deduplicates strings naturally.
|
|
215
|
+
* @param {string[]} values Source values.
|
|
216
|
+
* @returns {string[]}
|
|
217
|
+
*/
|
|
218
|
+
static #sortedStrings(values) {
|
|
219
|
+
return [...new Set((values || []).filter(Boolean))].sort(
|
|
220
|
+
(left, right) =>
|
|
221
|
+
left.localeCompare(right, undefined, { numeric: true })
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Converts a value to a deterministic lowercase key segment.
|
|
227
|
+
* @param {unknown} value Source value.
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
static #slug(value) {
|
|
231
|
+
return (
|
|
232
|
+
String(value || '')
|
|
233
|
+
.trim()
|
|
234
|
+
.toLowerCase()
|
|
235
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
236
|
+
.replace(/^-+|-+$/gu, '') || 'item'
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Normalizes a layer or semantic lookup name.
|
|
242
|
+
* @param {unknown} value Source value.
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
static #lookupName(value) {
|
|
246
|
+
return String(value || '')
|
|
247
|
+
.trim()
|
|
248
|
+
.toLowerCase()
|
|
249
|
+
.replace(/\s+/gu, ' ')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Removes empty fields while preserving zeros and false.
|
|
254
|
+
* @param {Record<string, unknown>} value Candidate object.
|
|
255
|
+
* @returns {Record<string, unknown>}
|
|
256
|
+
*/
|
|
257
|
+
static #stripEmpty(value) {
|
|
258
|
+
return Object.fromEntries(
|
|
259
|
+
Object.entries(value || {}).filter(([, entryValue]) => {
|
|
260
|
+
if (Array.isArray(entryValue)) return entryValue.length > 0
|
|
261
|
+
return (
|
|
262
|
+
entryValue !== null &&
|
|
263
|
+
entryValue !== undefined &&
|
|
264
|
+
entryValue !== ''
|
|
265
|
+
)
|
|
266
|
+
})
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|