altium-toolkit 1.0.10 → 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.
Files changed (63) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +4 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +132 -1
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +692 -2
  11. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  12. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  13. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  14. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  15. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  16. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  17. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  18. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  19. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  20. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  21. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  22. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  23. package/package.json +1 -1
  24. package/src/core/altium/AltiumParser.mjs +7 -2
  25. package/src/core/altium/CiArtifactBundleBuilder.mjs +16 -5
  26. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  27. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  28. package/src/core/altium/DraftsmanDigestParser.mjs +246 -7
  29. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  30. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  31. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  32. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  33. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  34. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  35. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  36. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  37. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  38. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  39. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  40. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  41. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  42. package/src/core/altium/PcbModelParser.mjs +182 -18
  43. package/src/core/altium/PcbPickPlacePositionResolver.mjs +3 -0
  44. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  45. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  46. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  47. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  48. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  49. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  50. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  51. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  52. package/src/core/altium/PrjPcbModelParser.mjs +45 -0
  53. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  54. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  55. package/src/core/altium/ProjectDesignBundleBuilder.mjs +61 -2
  56. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  57. package/src/core/altium/SvgModelCrossLinkValidator.mjs +35 -2
  58. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +164 -0
  59. package/src/parser.mjs +15 -0
  60. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  61. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  62. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  63. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -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
+ }
@@ -0,0 +1,298 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds route-highlight profile rows for PCB review metadata.
7
+ */
8
+ export class PcbReviewRouteHighlightProfileBuilder {
9
+ /**
10
+ * Builds route-highlight profiles for classes, pairs, pair classes, and nets.
11
+ * @param {object} routeAnalysis Route analysis model.
12
+ * @returns {object[]}
13
+ */
14
+ static build(routeAnalysis = {}) {
15
+ return [
16
+ ...PcbReviewRouteHighlightProfileBuilder.#differentialPairProfiles(
17
+ routeAnalysis
18
+ ),
19
+ ...PcbReviewRouteHighlightProfileBuilder.#differentialPairClassProfiles(
20
+ routeAnalysis
21
+ ),
22
+ ...PcbReviewRouteHighlightProfileBuilder.#netClassProfiles(
23
+ routeAnalysis
24
+ ),
25
+ ...PcbReviewRouteHighlightProfileBuilder.#netProfiles(routeAnalysis)
26
+ ]
27
+ }
28
+
29
+ /**
30
+ * Builds net-class highlight profiles.
31
+ * @param {object} routeAnalysis Route analysis model.
32
+ * @returns {object[]}
33
+ */
34
+ static #netClassProfiles(routeAnalysis) {
35
+ return (routeAnalysis.classes || []).map((classRow) =>
36
+ PcbReviewRouteHighlightProfileBuilder.#highlightProfile({
37
+ selectorKind: 'net-class',
38
+ keyPrefix: 'highlight-net-class-',
39
+ name: classRow.name,
40
+ netNames: classRow.netNames || [],
41
+ routeAnalysis
42
+ })
43
+ )
44
+ }
45
+
46
+ /**
47
+ * Builds differential-pair highlight profiles.
48
+ * @param {object} routeAnalysis Route analysis model.
49
+ * @returns {object[]}
50
+ */
51
+ static #differentialPairProfiles(routeAnalysis) {
52
+ return (routeAnalysis.differentialPairs || []).map((pair) =>
53
+ PcbReviewRouteHighlightProfileBuilder.#highlightProfile({
54
+ selectorKind: 'differential-pair',
55
+ keyPrefix: 'highlight-diff-pair-',
56
+ name: pair.name,
57
+ netNames: [pair.positiveNetName, pair.negativeNetName].filter(
58
+ Boolean
59
+ ),
60
+ routeAnalysis
61
+ })
62
+ )
63
+ }
64
+
65
+ /**
66
+ * Builds differential-pair class highlight profiles.
67
+ * @param {object} routeAnalysis Route analysis model.
68
+ * @returns {object[]}
69
+ */
70
+ static #differentialPairClassProfiles(routeAnalysis) {
71
+ const classNames = new Map()
72
+ for (const pair of routeAnalysis.differentialPairs || []) {
73
+ for (const className of pair.classes || []) {
74
+ if (!classNames.has(className)) {
75
+ classNames.set(className, new Set())
76
+ }
77
+ const netNames = classNames.get(className)
78
+ for (const netName of [
79
+ pair.positiveNetName,
80
+ pair.negativeNetName
81
+ ]) {
82
+ if (netName) {
83
+ netNames.add(netName)
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ return [...classNames.entries()]
90
+ .sort(([left], [right]) =>
91
+ left.localeCompare(right, undefined, { numeric: true })
92
+ )
93
+ .map(([className, netNames]) =>
94
+ PcbReviewRouteHighlightProfileBuilder.#highlightProfile({
95
+ selectorKind: 'differential-pair-class',
96
+ keyPrefix: 'highlight-diff-pair-class-',
97
+ name: className,
98
+ netNames: [...netNames],
99
+ routeAnalysis
100
+ })
101
+ )
102
+ }
103
+
104
+ /**
105
+ * Builds scalar net highlight profiles.
106
+ * @param {object} routeAnalysis Route analysis model.
107
+ * @returns {object[]}
108
+ */
109
+ static #netProfiles(routeAnalysis) {
110
+ return (routeAnalysis.byNet || []).map((net) =>
111
+ PcbReviewRouteHighlightProfileBuilder.#highlightProfile({
112
+ selectorKind: 'net',
113
+ keyPrefix: 'highlight-net-',
114
+ name: net.netName,
115
+ netNames: [net.netName],
116
+ routeAnalysis
117
+ })
118
+ )
119
+ }
120
+
121
+ /**
122
+ * Builds one route-highlight profile.
123
+ * @param {{ selectorKind: string, keyPrefix: string, name: string, netNames: string[], routeAnalysis: object }} options Profile options.
124
+ * @returns {object}
125
+ */
126
+ static #highlightProfile(options) {
127
+ const netNames = PcbReviewRouteHighlightProfileBuilder.#sortedStrings(
128
+ options.netNames || []
129
+ )
130
+ const layerGroups = PcbReviewRouteHighlightProfileBuilder.#layerGroups(
131
+ options.routeAnalysis,
132
+ netNames
133
+ )
134
+
135
+ return PcbReviewRouteHighlightProfileBuilder.#stripEmpty({
136
+ key:
137
+ options.keyPrefix +
138
+ PcbReviewRouteHighlightProfileBuilder.#slug(options.name),
139
+ selectorKind: options.selectorKind,
140
+ name: options.name,
141
+ netNames,
142
+ minRoutedLengthMil:
143
+ layerGroups.length > 0
144
+ ? Math.min(
145
+ ...layerGroups.map((group) =>
146
+ Number(group.routedLengthMil || 0)
147
+ )
148
+ )
149
+ : 0,
150
+ layerGroups,
151
+ style: PcbReviewRouteHighlightProfileBuilder.#highlightStyle(
152
+ options.selectorKind
153
+ )
154
+ })
155
+ }
156
+
157
+ /**
158
+ * Builds per-layer route-highlight groups.
159
+ * @param {object} routeAnalysis Route analysis model.
160
+ * @param {string[]} netNames Net names.
161
+ * @returns {object[]}
162
+ */
163
+ static #layerGroups(routeAnalysis, netNames) {
164
+ const groupsByLayer = new Map()
165
+ for (const net of PcbReviewRouteHighlightProfileBuilder.#netsByName(
166
+ routeAnalysis,
167
+ netNames
168
+ )) {
169
+ for (const participation of net.layerParticipation || []) {
170
+ const layerKey = participation.layerKey || ''
171
+ if (!layerKey) {
172
+ continue
173
+ }
174
+ if (!groupsByLayer.has(layerKey)) {
175
+ groupsByLayer.set(layerKey, {
176
+ layerKey,
177
+ primitiveKeys: new Set(),
178
+ routedLengthMil: 0
179
+ })
180
+ }
181
+ const group = groupsByLayer.get(layerKey)
182
+ group.routedLengthMil += Number(
183
+ participation.totalLengthMil || 0
184
+ )
185
+ for (const routeGroup of net.connectedRouteGroups || []) {
186
+ if (!(routeGroup.layerKeys || []).includes(layerKey)) {
187
+ continue
188
+ }
189
+ for (const primitiveKey of routeGroup.primitiveKeys || []) {
190
+ group.primitiveKeys.add(primitiveKey)
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ return [...groupsByLayer.values()]
197
+ .map((group) => ({
198
+ layerKey: group.layerKey,
199
+ primitiveKeys:
200
+ PcbReviewRouteHighlightProfileBuilder.#sortedStrings([
201
+ ...group.primitiveKeys
202
+ ]),
203
+ routedLengthMil: PcbReviewRouteHighlightProfileBuilder.#round(
204
+ group.routedLengthMil
205
+ )
206
+ }))
207
+ .sort((left, right) =>
208
+ left.layerKey.localeCompare(right.layerKey, undefined, {
209
+ numeric: true
210
+ })
211
+ )
212
+ }
213
+
214
+ /**
215
+ * Resolves net route rows by name.
216
+ * @param {object} routeAnalysis Route analysis model.
217
+ * @param {string[]} netNames Net names.
218
+ * @returns {object[]}
219
+ */
220
+ static #netsByName(routeAnalysis, netNames) {
221
+ const wanted = new Set(netNames || [])
222
+ return (routeAnalysis.byNet || []).filter((net) =>
223
+ wanted.has(net.netName)
224
+ )
225
+ }
226
+
227
+ /**
228
+ * Returns deterministic highlight style metadata.
229
+ * @param {string} selectorKind Selector kind.
230
+ * @returns {{ highlightColor: string, contextColor: string }}
231
+ */
232
+ static #highlightStyle(selectorKind) {
233
+ if (selectorKind === 'differential-pair') {
234
+ return { highlightColor: '#dc2626', contextColor: '#475569' }
235
+ }
236
+ if (selectorKind === 'differential-pair-class') {
237
+ return { highlightColor: '#7c3aed', contextColor: '#475569' }
238
+ }
239
+ if (selectorKind === 'net-class') {
240
+ return { highlightColor: '#d97706', contextColor: '#475569' }
241
+ }
242
+ return { highlightColor: '#2563eb', contextColor: '#475569' }
243
+ }
244
+
245
+ /**
246
+ * Sorts and deduplicates strings naturally.
247
+ * @param {string[]} values Source values.
248
+ * @returns {string[]}
249
+ */
250
+ static #sortedStrings(values) {
251
+ return [...new Set((values || []).filter(Boolean))].sort(
252
+ (left, right) =>
253
+ left.localeCompare(right, undefined, { numeric: true })
254
+ )
255
+ }
256
+
257
+ /**
258
+ * Converts a value to a deterministic lowercase key segment.
259
+ * @param {unknown} value Source value.
260
+ * @returns {string}
261
+ */
262
+ static #slug(value) {
263
+ return (
264
+ String(value || '')
265
+ .trim()
266
+ .toLowerCase()
267
+ .replace(/[^a-z0-9]+/gu, '-')
268
+ .replace(/^-+|-+$/gu, '') || 'item'
269
+ )
270
+ }
271
+
272
+ /**
273
+ * Rounds numeric values for stable JSON.
274
+ * @param {number} value Candidate number.
275
+ * @returns {number}
276
+ */
277
+ static #round(value) {
278
+ return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : 0
279
+ }
280
+
281
+ /**
282
+ * Removes empty fields while preserving zeros and false.
283
+ * @param {Record<string, unknown>} value Candidate object.
284
+ * @returns {Record<string, unknown>}
285
+ */
286
+ static #stripEmpty(value) {
287
+ return Object.fromEntries(
288
+ Object.entries(value || {}).filter(([, entryValue]) => {
289
+ if (Array.isArray(entryValue)) return entryValue.length > 0
290
+ return (
291
+ entryValue !== null &&
292
+ entryValue !== undefined &&
293
+ entryValue !== ''
294
+ )
295
+ })
296
+ )
297
+ }
298
+ }