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
|
@@ -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
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds drill overlay and draw-order review metadata.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbReviewDrillMetadataBuilder {
|
|
9
|
+
/**
|
|
10
|
+
* Builds drill review and draw-order metadata.
|
|
11
|
+
* @param {object} pcb Normalized PCB model.
|
|
12
|
+
* @returns {{ overlays: object[], layerDrawOrder: object[] }}
|
|
13
|
+
*/
|
|
14
|
+
static build(pcb = {}) {
|
|
15
|
+
return {
|
|
16
|
+
overlays: [
|
|
17
|
+
...PcbReviewDrillMetadataBuilder.#drillRows(
|
|
18
|
+
'via',
|
|
19
|
+
pcb.vias || []
|
|
20
|
+
),
|
|
21
|
+
...PcbReviewDrillMetadataBuilder.#drillRows(
|
|
22
|
+
'pad',
|
|
23
|
+
pcb.pads || []
|
|
24
|
+
)
|
|
25
|
+
],
|
|
26
|
+
layerDrawOrder: PcbReviewDrillMetadataBuilder.#layerDrawOrder(pcb)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Builds drill overlay rows for one drill-owner collection.
|
|
32
|
+
* @param {'via' | 'pad'} ownerKind Drill owner kind.
|
|
33
|
+
* @param {object[]} owners Drill owners.
|
|
34
|
+
* @returns {object[]}
|
|
35
|
+
*/
|
|
36
|
+
static #drillRows(ownerKind, owners) {
|
|
37
|
+
return (owners || [])
|
|
38
|
+
.map((owner, index) => {
|
|
39
|
+
if (!PcbReviewDrillMetadataBuilder.#hasHole(owner)) {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
const ownerKey = ownerKind + '-' + index
|
|
43
|
+
const holeKind = PcbReviewDrillMetadataBuilder.#holeKind(owner)
|
|
44
|
+
const plating =
|
|
45
|
+
owner?.isPlated === false ? 'non-plated' : 'plated'
|
|
46
|
+
const renderState =
|
|
47
|
+
PcbReviewDrillMetadataBuilder.#drillRenderState(owner)
|
|
48
|
+
|
|
49
|
+
return PcbReviewDrillMetadataBuilder.#stripEmpty({
|
|
50
|
+
elementKey: 'pcb-' + ownerKind + '-hole-' + String(index),
|
|
51
|
+
ownerKind,
|
|
52
|
+
ownerKey,
|
|
53
|
+
holeKind,
|
|
54
|
+
plating,
|
|
55
|
+
renderState,
|
|
56
|
+
overlayKind: PcbReviewDrillMetadataBuilder.#overlayKind(
|
|
57
|
+
ownerKind,
|
|
58
|
+
holeKind,
|
|
59
|
+
plating,
|
|
60
|
+
renderState
|
|
61
|
+
),
|
|
62
|
+
layerKeys: PcbReviewDrillMetadataBuilder.#sortedStrings([
|
|
63
|
+
PcbReviewDrillMetadataBuilder.#layerKey(owner)
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Builds layer draw-order rows for visual review.
|
|
72
|
+
* @param {object} pcb PCB model.
|
|
73
|
+
* @returns {object[]}
|
|
74
|
+
*/
|
|
75
|
+
static #layerDrawOrder(pcb) {
|
|
76
|
+
const descriptors = new Map()
|
|
77
|
+
for (const layer of [
|
|
78
|
+
...(pcb.layers || []),
|
|
79
|
+
...(pcb.primitiveLayers || [])
|
|
80
|
+
]) {
|
|
81
|
+
const layerId = PcbReviewDrillMetadataBuilder.#layerId(layer)
|
|
82
|
+
if (!Number.isInteger(layerId) || descriptors.has(layerId)) {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
descriptors.set(layerId, {
|
|
86
|
+
layerKey: 'L' + layerId,
|
|
87
|
+
layerId,
|
|
88
|
+
displayName:
|
|
89
|
+
layer.displayName || layer.name || 'Layer ' + layerId,
|
|
90
|
+
role: PcbReviewDrillMetadataBuilder.#layerRole(layer, layerId)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let internalOrder = 0
|
|
95
|
+
return [...descriptors.values()]
|
|
96
|
+
.sort((left, right) => left.layerId - right.layerId)
|
|
97
|
+
.map((layer, drawOrder) => {
|
|
98
|
+
const row = { ...layer, drawOrder }
|
|
99
|
+
if (layer.role === 'internal') {
|
|
100
|
+
internalOrder += 1
|
|
101
|
+
row.internalOrder = internalOrder
|
|
102
|
+
}
|
|
103
|
+
return row
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Returns true when a drill owner has a visible hole.
|
|
109
|
+
* @param {object} owner Drill owner primitive.
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
static #hasHole(owner) {
|
|
113
|
+
return Number(owner?.holeDiameter || owner?.drillDiameter || 0) > 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolves a drill owner hole kind.
|
|
118
|
+
* @param {object} owner Drill owner primitive.
|
|
119
|
+
* @returns {'round' | 'slot'}
|
|
120
|
+
*/
|
|
121
|
+
static #holeKind(owner) {
|
|
122
|
+
const holeShape = String(owner?.holeShape || '').toLowerCase()
|
|
123
|
+
if (
|
|
124
|
+
Number(owner?.holeSlotLength || owner?.slotLength || 0) > 0 ||
|
|
125
|
+
holeShape.includes('slot')
|
|
126
|
+
) {
|
|
127
|
+
return 'slot'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return 'round'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolves drill rendering state from explicit and via-protection metadata.
|
|
135
|
+
* @param {object} owner Drill owner primitive.
|
|
136
|
+
* @returns {'open' | 'covered' | 'filled' | 'capped'}
|
|
137
|
+
*/
|
|
138
|
+
static #drillRenderState(owner) {
|
|
139
|
+
const explicit =
|
|
140
|
+
owner?.drillRenderState ||
|
|
141
|
+
owner?.renderState ||
|
|
142
|
+
owner?.drill?.renderState
|
|
143
|
+
if (explicit) {
|
|
144
|
+
return PcbReviewDrillMetadataBuilder.#normalizeRenderState(explicit)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const featureText = (owner?.viaProtection?.features || [])
|
|
148
|
+
.flatMap((feature) => [feature.type, feature.material])
|
|
149
|
+
.join(' ')
|
|
150
|
+
.toLowerCase()
|
|
151
|
+
|
|
152
|
+
if (/cap/u.test(featureText)) return 'capped'
|
|
153
|
+
if (/fill|plug/u.test(featureText)) return 'filled'
|
|
154
|
+
if (/cover|tent|mask/u.test(featureText)) return 'covered'
|
|
155
|
+
|
|
156
|
+
const ipcType = Number(
|
|
157
|
+
owner?.ipc4761Type ?? owner?.viaProtection?.ipc4761Type
|
|
158
|
+
)
|
|
159
|
+
if (ipcType === 6 || ipcType === 7) return 'capped'
|
|
160
|
+
if (ipcType === 3 || ipcType === 4 || ipcType === 5) return 'filled'
|
|
161
|
+
if (ipcType === 1 || ipcType === 2) return 'covered'
|
|
162
|
+
|
|
163
|
+
return 'open'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Normalizes a render-state label.
|
|
168
|
+
* @param {unknown} value Raw render-state value.
|
|
169
|
+
* @returns {'open' | 'covered' | 'filled' | 'capped'}
|
|
170
|
+
*/
|
|
171
|
+
static #normalizeRenderState(value) {
|
|
172
|
+
const normalized = String(value || '').toLowerCase()
|
|
173
|
+
if (/cap/u.test(normalized)) return 'capped'
|
|
174
|
+
if (/fill|plug/u.test(normalized)) return 'filled'
|
|
175
|
+
if (/cover|tent|mask/u.test(normalized)) return 'covered'
|
|
176
|
+
return 'open'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolves a deterministic overlay kind.
|
|
181
|
+
* @param {'via' | 'pad'} ownerKind Drill owner kind.
|
|
182
|
+
* @param {'round' | 'slot'} holeKind Hole kind.
|
|
183
|
+
* @param {'plated' | 'non-plated'} plating Plating state.
|
|
184
|
+
* @param {'open' | 'covered' | 'filled' | 'capped'} renderState Render state.
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
static #overlayKind(ownerKind, holeKind, plating, renderState) {
|
|
188
|
+
if (plating === 'non-plated') {
|
|
189
|
+
return holeKind === 'slot' ? 'non-plated-slot' : 'non-plated-hole'
|
|
190
|
+
}
|
|
191
|
+
if (ownerKind === 'via' && ['filled', 'capped'].includes(renderState)) {
|
|
192
|
+
return 'filled-or-capped-via'
|
|
193
|
+
}
|
|
194
|
+
if (ownerKind === 'via' && renderState === 'covered') {
|
|
195
|
+
return 'covered-via'
|
|
196
|
+
}
|
|
197
|
+
return holeKind === 'slot' ? 'plated-slot' : 'plated-hole'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolves a layer role suitable for visual draw order.
|
|
202
|
+
* @param {object} layer Layer row.
|
|
203
|
+
* @param {number} layerId Layer id.
|
|
204
|
+
* @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
static #layerRole(layer, layerId) {
|
|
207
|
+
const label = [layer?.role, layer?.kind, layer?.name]
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
.join(' ')
|
|
210
|
+
.toLowerCase()
|
|
211
|
+
if (label.includes('overlay') || label.includes('silk')) {
|
|
212
|
+
return 'overlay'
|
|
213
|
+
}
|
|
214
|
+
if (layerId === 1 || layerId === 32) {
|
|
215
|
+
return 'surface'
|
|
216
|
+
}
|
|
217
|
+
if (layerId > 1 && layerId < 32) {
|
|
218
|
+
return 'internal'
|
|
219
|
+
}
|
|
220
|
+
if (label.includes('mechanical')) {
|
|
221
|
+
return 'mechanical'
|
|
222
|
+
}
|
|
223
|
+
return label.includes('copper') ? 'surface' : 'other'
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolves a layer key from a primitive.
|
|
228
|
+
* @param {object} value Primitive row.
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
static #layerKey(value) {
|
|
232
|
+
const layerId = PcbReviewDrillMetadataBuilder.#layerId(value)
|
|
233
|
+
if (Number.isInteger(layerId)) {
|
|
234
|
+
return 'L' + layerId
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const layer = String(value?.layer || value?.layerName || '').trim()
|
|
238
|
+
return layer ? 'L-' + PcbReviewDrillMetadataBuilder.#slug(layer) : ''
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resolves a numeric layer id.
|
|
243
|
+
* @param {object} value Primitive or layer descriptor.
|
|
244
|
+
* @returns {number | undefined}
|
|
245
|
+
*/
|
|
246
|
+
static #layerId(value) {
|
|
247
|
+
for (const key of ['layerId', 'layerCode', 'id', 'index']) {
|
|
248
|
+
const layerId = Number(value?.[key])
|
|
249
|
+
if (Number.isInteger(layerId)) {
|
|
250
|
+
return layerId
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Sorts and deduplicates strings naturally.
|
|
259
|
+
* @param {string[]} values Source values.
|
|
260
|
+
* @returns {string[]}
|
|
261
|
+
*/
|
|
262
|
+
static #sortedStrings(values) {
|
|
263
|
+
return [...new Set((values || []).filter(Boolean))].sort(
|
|
264
|
+
(left, right) =>
|
|
265
|
+
left.localeCompare(right, undefined, { numeric: true })
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Converts a value to a deterministic lowercase key segment.
|
|
271
|
+
* @param {unknown} value Source value.
|
|
272
|
+
* @returns {string}
|
|
273
|
+
*/
|
|
274
|
+
static #slug(value) {
|
|
275
|
+
return (
|
|
276
|
+
String(value || '')
|
|
277
|
+
.trim()
|
|
278
|
+
.toLowerCase()
|
|
279
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
280
|
+
.replace(/^-+|-+$/gu, '') || 'item'
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Removes empty fields while preserving zeros and false.
|
|
286
|
+
* @param {Record<string, unknown>} value Candidate object.
|
|
287
|
+
* @returns {Record<string, unknown>}
|
|
288
|
+
*/
|
|
289
|
+
static #stripEmpty(value) {
|
|
290
|
+
return Object.fromEntries(
|
|
291
|
+
Object.entries(value || {}).filter(([, entryValue]) => {
|
|
292
|
+
if (Array.isArray(entryValue)) return entryValue.length > 0
|
|
293
|
+
return (
|
|
294
|
+
entryValue !== null &&
|
|
295
|
+
entryValue !== undefined &&
|
|
296
|
+
entryValue !== ''
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
}
|