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.
Files changed (30) hide show
  1. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
  2. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
  3. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +160 -1
  5. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  8. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  9. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  10. package/package.json +1 -1
  11. package/src/core/altium/AltiumParser.mjs +7 -2
  12. package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
  13. package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
  14. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  15. package/src/core/altium/PcbModelParser.mjs +29 -4
  16. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  17. package/src/core/altium/PcbPickPlacePositionResolver.mjs +8 -1
  18. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  19. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  20. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  21. package/src/core/altium/PrjPcbModelParser.mjs +24 -2
  22. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  23. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  24. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  25. package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
  26. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
  27. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  28. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  29. package/src/parser.mjs +6 -0
  30. 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, M1, ... fields.
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) => Number(left.slice(1)) - Number(right.slice(1))
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
- ...PcbPadStackParser.#parseExtensionRecord(
94
- extensionRecord,
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: Number(properties.KIND || 0),
180
+ kind,
181
+ ...legacyCutout.fields,
175
182
  isKeepout: flags2 === 2,
176
183
  isBoardCutout:
184
+ legacyCutout.isBoardCutout ||
177
185
  String(properties.ISBOARDCUTOUT || '').toUpperCase() ===
178
- 'TRUE',
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