altium-toolkit 0.1.1 → 0.1.16

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 (54) hide show
  1. package/README.md +24 -6
  2. package/docs/api.md +42 -4
  3. package/docs/model-format.md +95 -5
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
  5. package/docs/testing.md +7 -2
  6. package/package.json +6 -2
  7. package/spec/library-scope.md +7 -1
  8. package/src/core/altium/AltiumParser.mjs +22 -325
  9. package/src/core/altium/NormalizedModelSchema.mjs +28 -0
  10. package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
  11. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
  12. package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
  13. package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
  14. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
  15. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
  16. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
  17. package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
  18. package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
  19. package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
  20. package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
  21. package/src/core/altium/PcbLibModelParser.mjs +202 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
  23. package/src/core/altium/PcbModelParser.mjs +618 -66
  24. package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
  25. package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
  26. package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
  27. package/src/core/altium/PcbPadStackParser.mjs +903 -0
  28. package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
  29. package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
  30. package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
  31. package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
  32. package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
  33. package/src/core/altium/PcbRuleParser.mjs +587 -0
  34. package/src/core/altium/PcbStreamExtractor.mjs +127 -4
  35. package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
  36. package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
  37. package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
  38. package/src/core/altium/PcbViaStackParser.mjs +548 -0
  39. package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
  40. package/src/core/altium/PrjPcbModelParser.mjs +797 -0
  41. package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
  42. package/src/parser.mjs +13 -0
  43. package/src/renderers.mjs +5 -0
  44. package/src/styles/altium-renderers.css +11 -6
  45. package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
  46. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
  47. package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
  48. package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
  49. package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
  50. package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
  51. package/src/ui/PcbSvgRenderer.mjs +101 -109
  52. package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
  53. package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
  54. package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
@@ -0,0 +1,355 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ const { getDisplayText, getField } = ParserUtils
8
+
9
+ /**
10
+ * Resolves visible schematic component labels from owner-linked and nearby text.
11
+ */
12
+ export class SchematicComponentTextResolver {
13
+ /**
14
+ * Resolves a component designator from owner-linked text or nearby labels.
15
+ * @param {{ fields: Record<string, string | string[]> }[]} ownerTexts
16
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
17
+ * @param {{ x: number, y: number, libReference: string }} component
18
+ * @returns {string}
19
+ */
20
+ static resolveDesignator(ownerTexts, texts, component) {
21
+ const ownerDesignator = SchematicComponentTextResolver.#findRelatedText(
22
+ ownerTexts,
23
+ 'Designator'
24
+ )
25
+ if (
26
+ SchematicComponentTextResolver.#isResolvedComponentText(
27
+ ownerDesignator
28
+ )
29
+ ) {
30
+ return ownerDesignator
31
+ }
32
+
33
+ return SchematicComponentTextResolver.#findNearbyComponentDesignator(
34
+ texts,
35
+ component
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Resolves a component value from owner-linked text or nearby labels.
41
+ * @param {{ fields: Record<string, string | string[]> }[]} ownerTexts
42
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
43
+ * @param {{ x: number, y: number, libReference: string }} component
44
+ * @returns {string}
45
+ */
46
+ static resolveValue(ownerTexts, texts, component) {
47
+ const ownerValue =
48
+ SchematicComponentTextResolver.#findRelatedText(
49
+ ownerTexts,
50
+ 'Comment'
51
+ ) ||
52
+ SchematicComponentTextResolver.#findRelatedText(ownerTexts, 'VALUE')
53
+ if (
54
+ SchematicComponentTextResolver.#isResolvedComponentText(ownerValue)
55
+ ) {
56
+ return ownerValue
57
+ }
58
+
59
+ return (
60
+ SchematicComponentTextResolver.#findNearbyComponentText(
61
+ texts,
62
+ component,
63
+ ['comment', 'value'],
64
+ '',
65
+ SchematicComponentTextResolver.#inferComponentValueHint(
66
+ component.libReference
67
+ )
68
+ ) || ownerValue
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Finds a related text value by name.
74
+ * @param {{ fields: Record<string, string | string[]> }[]} records
75
+ * @param {string} logicalName
76
+ * @returns {string}
77
+ */
78
+ static #findRelatedText(records, logicalName) {
79
+ const match = records.find(
80
+ (record) =>
81
+ getField(record.fields, 'Name').toLowerCase() ===
82
+ logicalName.toLowerCase()
83
+ )
84
+ return match ? getDisplayText(match.fields) : ''
85
+ }
86
+
87
+ /**
88
+ * Finds the closest nearby designator text for one component.
89
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
90
+ * @param {{ x: number, y: number, libReference: string }} component
91
+ * @returns {string}
92
+ */
93
+ static #findNearbyComponentDesignator(texts, component) {
94
+ const expectedPrefix =
95
+ SchematicComponentTextResolver.#inferComponentDesignatorPrefix(
96
+ component.libReference
97
+ )
98
+ const expectedValueHint =
99
+ SchematicComponentTextResolver.#inferComponentValueHint(
100
+ component.libReference
101
+ )
102
+ const candidates =
103
+ SchematicComponentTextResolver.#collectNearbyComponentTextCandidates(
104
+ texts,
105
+ component,
106
+ ['designator']
107
+ )
108
+ const scopedCandidates = expectedPrefix
109
+ ? candidates.filter((candidate) =>
110
+ candidate.text
111
+ .toUpperCase()
112
+ .startsWith(expectedPrefix.toUpperCase())
113
+ )
114
+ : candidates
115
+ const usableCandidates = scopedCandidates.length
116
+ ? scopedCandidates
117
+ : candidates
118
+ const rankedCandidates = usableCandidates
119
+ .map((candidate) => ({
120
+ ...candidate,
121
+ score:
122
+ candidate.distance +
123
+ SchematicComponentTextResolver.#scoreAssociatedValueMismatch(
124
+ texts,
125
+ candidate,
126
+ expectedValueHint
127
+ )
128
+ }))
129
+ .sort((left, right) => left.score - right.score)
130
+
131
+ return rankedCandidates[0]?.text || ''
132
+ }
133
+
134
+ /**
135
+ * Finds the closest nearby visible text for one component.
136
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
137
+ * @param {{ x: number, y: number }} component
138
+ * @param {string[]} logicalNames
139
+ * @param {string} expectedPrefix
140
+ * @param {string} expectedTextHint
141
+ * @returns {string}
142
+ */
143
+ static #findNearbyComponentText(
144
+ texts,
145
+ component,
146
+ logicalNames,
147
+ expectedPrefix = '',
148
+ expectedTextHint = ''
149
+ ) {
150
+ const candidates =
151
+ SchematicComponentTextResolver.#collectNearbyComponentTextCandidates(
152
+ texts,
153
+ component,
154
+ logicalNames
155
+ )
156
+ const prefixedCandidates = expectedPrefix
157
+ ? candidates.filter((candidate) =>
158
+ candidate.text
159
+ .toUpperCase()
160
+ .startsWith(expectedPrefix.toUpperCase())
161
+ )
162
+ : candidates
163
+ const scopedCandidates = prefixedCandidates.length
164
+ ? prefixedCandidates
165
+ : candidates
166
+ const hintedCandidates = expectedTextHint
167
+ ? scopedCandidates.filter((candidate) =>
168
+ SchematicComponentTextResolver.#normalizeTextMatch(
169
+ candidate.text
170
+ ).includes(
171
+ SchematicComponentTextResolver.#normalizeTextMatch(
172
+ expectedTextHint
173
+ )
174
+ )
175
+ )
176
+ : scopedCandidates
177
+ const usableCandidates = hintedCandidates.length
178
+ ? hintedCandidates
179
+ : scopedCandidates
180
+
181
+ return usableCandidates.sort(
182
+ (left, right) => left.distance - right.distance
183
+ )[0]?.text
184
+ }
185
+
186
+ /**
187
+ * Collects nearby visible schematic text candidates around one component.
188
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
189
+ * @param {{ x: number, y: number }} component
190
+ * @param {string[]} logicalNames
191
+ * @returns {{ x: number, y: number, text: string, distance: number }[]}
192
+ */
193
+ static #collectNearbyComponentTextCandidates(
194
+ texts,
195
+ component,
196
+ logicalNames
197
+ ) {
198
+ const allowedNames = new Set(
199
+ logicalNames.map((name) => name.toLowerCase())
200
+ )
201
+
202
+ return texts
203
+ .filter((text) =>
204
+ allowedNames.has(
205
+ String(text.name || '')
206
+ .trim()
207
+ .toLowerCase()
208
+ )
209
+ )
210
+ .map((text) => ({
211
+ x: text.x,
212
+ y: text.y,
213
+ text: text.text,
214
+ distance:
215
+ Math.abs(text.x - component.x) +
216
+ Math.abs(text.y - component.y)
217
+ }))
218
+ .filter(
219
+ (text) =>
220
+ Math.abs(text.x - component.x) <= 80 &&
221
+ Math.abs(text.y - component.y) <= 80
222
+ )
223
+ }
224
+
225
+ /**
226
+ * Penalizes a designator candidate when nearby value text mismatches.
227
+ * @param {{ x: number, y: number, text: string, name: string }[]} texts
228
+ * @param {{ x: number, y: number }} candidate
229
+ * @param {string} expectedValueHint
230
+ * @returns {number}
231
+ */
232
+ static #scoreAssociatedValueMismatch(texts, candidate, expectedValueHint) {
233
+ if (!expectedValueHint) {
234
+ return 0
235
+ }
236
+
237
+ const associatedValue =
238
+ SchematicComponentTextResolver.#findNearbyComponentText(
239
+ texts,
240
+ candidate,
241
+ ['comment', 'value']
242
+ )
243
+ if (!associatedValue) {
244
+ return 0
245
+ }
246
+
247
+ return SchematicComponentTextResolver.#normalizeTextMatch(
248
+ associatedValue
249
+ ).includes(
250
+ SchematicComponentTextResolver.#normalizeTextMatch(
251
+ expectedValueHint
252
+ )
253
+ )
254
+ ? -30
255
+ : 30
256
+ }
257
+
258
+ /**
259
+ * Returns true when owner-linked text is usable as a display value.
260
+ * @param {string} value
261
+ * @returns {boolean}
262
+ */
263
+ static #isResolvedComponentText(value) {
264
+ const normalized = String(value || '').trim()
265
+
266
+ return Boolean(
267
+ normalized && normalized !== '*' && !normalized.startsWith('=')
268
+ )
269
+ }
270
+
271
+ /**
272
+ * Infers the visible designator prefix from a library reference.
273
+ * @param {string} libReference
274
+ * @returns {string}
275
+ */
276
+ static #inferComponentDesignatorPrefix(libReference) {
277
+ const normalized = String(libReference || '')
278
+ .trim()
279
+ .toUpperCase()
280
+
281
+ if (normalized.startsWith('RES/')) return 'R'
282
+ if (normalized.startsWith('CAP/')) return 'C'
283
+ if (normalized.startsWith('DIODE/')) return 'D'
284
+ if (normalized.startsWith('CON/')) return 'J'
285
+ if (normalized.startsWith('IC/')) return 'U'
286
+
287
+ return ''
288
+ }
289
+
290
+ /**
291
+ * Infers the visible value label from a library reference.
292
+ * @param {string} libReference
293
+ * @returns {string}
294
+ */
295
+ static #inferComponentValueHint(libReference) {
296
+ const segments = String(libReference || '')
297
+ .split('/')
298
+ .map((segment) => segment.trim())
299
+ .filter(Boolean)
300
+
301
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
302
+ const segment = segments[index]
303
+
304
+ if (
305
+ SchematicComponentTextResolver.#isPackageLikeComponentSegment(
306
+ segment
307
+ ) ||
308
+ /\s/.test(segment)
309
+ ) {
310
+ continue
311
+ }
312
+
313
+ if (
314
+ /^(?:\d+(?:\.\d+)?(?:R|K|M|UF|NF|PF)|1N[A-Z0-9-]+)$/i.test(
315
+ segment
316
+ )
317
+ ) {
318
+ return segment
319
+ }
320
+
321
+ if (
322
+ /[A-Z]/i.test(segment) &&
323
+ /\d/.test(segment) &&
324
+ segment.length >= 6
325
+ ) {
326
+ return segment
327
+ }
328
+ }
329
+
330
+ return ''
331
+ }
332
+
333
+ /**
334
+ * Returns true when a library segment is package/rating-like.
335
+ * @param {string} segment
336
+ * @returns {boolean}
337
+ */
338
+ static #isPackageLikeComponentSegment(segment) {
339
+ return /^(?:CE|\d{4}|SC\d+|SOD-\d+|\d+(?:\.\d+)?V|\d+(?:\.\d+)?[%%])$/i.test(
340
+ String(segment || '').trim()
341
+ )
342
+ }
343
+
344
+ /**
345
+ * Normalizes a text fragment for proximity matching.
346
+ * @param {string} value
347
+ * @returns {string}
348
+ */
349
+ static #normalizeTextMatch(value) {
350
+ return String(value || '')
351
+ .toUpperCase()
352
+ .replaceAll(/\s+/g, '')
353
+ .replaceAll('%', '%')
354
+ }
355
+ }
package/src/parser.mjs CHANGED
@@ -10,12 +10,25 @@ export { AltiumParser } from './core/altium/AltiumParser.mjs'
10
10
  export { AltiumLayoutParser } from './core/altium/AltiumLayoutParser.mjs'
11
11
  export { AsciiRecordParser } from './core/altium/AsciiRecordParser.mjs'
12
12
  export { ParserUtils } from './core/altium/ParserUtils.mjs'
13
+ export { NormalizedModelSchema } from './core/altium/NormalizedModelSchema.mjs'
13
14
  export { PcbBinaryPrimitiveParser } from './core/altium/PcbBinaryPrimitiveParser.mjs'
15
+ export { PcbBoardRegionSemanticsParser } from './core/altium/PcbBoardRegionSemanticsParser.mjs'
16
+ export { PcbComponentPrimitiveIndexer } from './core/altium/PcbComponentPrimitiveIndexer.mjs'
17
+ export { PcbEmbeddedFontExtractor } from './core/altium/PcbEmbeddedFontExtractor.mjs'
14
18
  export { PcbEmbeddedModelExtractor } from './core/altium/PcbEmbeddedModelExtractor.mjs'
19
+ export { PcbFontMetricsParser } from './core/altium/PcbFontMetricsParser.mjs'
20
+ export { PcbLayerIdCodec } from './core/altium/PcbLayerIdCodec.mjs'
21
+ export { PcbLibModelParser } from './core/altium/PcbLibModelParser.mjs'
22
+ export { PcbLibStreamExtractor } from './core/altium/PcbLibStreamExtractor.mjs'
15
23
  export { PcbModelParser } from './core/altium/PcbModelParser.mjs'
16
24
  export { PcbOutlineRasterizer } from './core/altium/PcbOutlineRasterizer.mjs'
17
25
  export { PcbOutlineRecovery } from './core/altium/PcbOutlineRecovery.mjs'
26
+ export { PcbPadStackParser } from './core/altium/PcbPadStackParser.mjs'
27
+ export { PcbRawRecordRegistry } from './core/altium/PcbRawRecordRegistry.mjs'
28
+ export { PcbRuleParser } from './core/altium/PcbRuleParser.mjs'
18
29
  export { PcbStreamExtractor } from './core/altium/PcbStreamExtractor.mjs'
30
+ export { PcbViaStackParser } from './core/altium/PcbViaStackParser.mjs'
19
31
  export { PrintableTextDecoder } from './core/altium/PrintableTextDecoder.mjs'
32
+ export { PrjPcbModelParser } from './core/altium/PrjPcbModelParser.mjs'
20
33
  export { SchematicMultipartOwnerMatcher } from './core/altium/SchematicMultipartOwnerMatcher.mjs'
21
34
  export { SchematicStandaloneCalloutNormalizer } from './core/altium/SchematicStandaloneCalloutNormalizer.mjs'
package/src/renderers.mjs CHANGED
@@ -6,6 +6,11 @@ export { BomTableRenderer } from './ui/BomTableRenderer.mjs'
6
6
  export { PcbArcUtils } from './ui/PcbArcUtils.mjs'
7
7
  export { PcbEdgeFacingGlyphNormalizer } from './ui/PcbEdgeFacingGlyphNormalizer.mjs'
8
8
  export { PcbFootprintPrimitiveSelector } from './ui/PcbFootprintPrimitiveSelector.mjs'
9
+ export {
10
+ PcbSideResolvedRenderModel,
11
+ isCopperPrimitive,
12
+ preparePcbSideResolvedRenderModel
13
+ } from './ui/PcbSideResolvedRenderModel.mjs'
9
14
  export { PcbSvgRenderer } from './ui/PcbSvgRenderer.mjs'
10
15
  export { SchematicColorResolver } from './ui/SchematicColorResolver.mjs'
11
16
  export { SchematicContentLayout } from './ui/SchematicContentLayout.mjs'
@@ -186,7 +186,8 @@
186
186
  }
187
187
 
188
188
  .pcb-polygon,
189
- .pcb-fill {
189
+ .pcb-fill,
190
+ .pcb-region {
190
191
  stroke: none;
191
192
  }
192
193
 
@@ -207,11 +208,13 @@
207
208
  fill: var(--pcb-subsurface-copper-fill);
208
209
  }
209
210
 
210
- .pcb-copper--surface .pcb-fill {
211
+ .pcb-copper--surface .pcb-fill,
212
+ .pcb-copper--surface .pcb-region {
211
213
  fill: var(--pcb-surface-fill);
212
214
  }
213
215
 
214
- .pcb-copper--subsurface .pcb-fill {
216
+ .pcb-copper--subsurface .pcb-fill,
217
+ .pcb-copper--subsurface .pcb-region {
215
218
  fill: var(--pcb-subsurface-fill);
216
219
  }
217
220
 
@@ -244,7 +247,8 @@
244
247
  fill: var(--pcb-copper-solid-fill);
245
248
  }
246
249
 
247
- .pcb-footprint-fill {
250
+ .pcb-footprint-fill,
251
+ .pcb-footprint-region {
248
252
  fill: var(--pcb-footprint-fill);
249
253
  }
250
254
 
@@ -263,11 +267,12 @@
263
267
  fill: var(--pcb-component-bottom-fill);
264
268
  }
265
269
 
266
- .pcb-component text {
270
+ .pcb-text {
267
271
  font-size: 29px;
268
- text-anchor: middle;
269
272
  fill: var(--pcb-component-text);
270
273
  font-weight: 700;
274
+ font-family: Arial, sans-serif;
275
+ pointer-events: none;
271
276
  }
272
277
 
273
278
  .bom-panel {
@@ -0,0 +1,113 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Splits PCB copper primitives into surface and subsurface render groups.
7
+ */
8
+ export class PcbCopperPrimitiveSplitter {
9
+ /**
10
+ * Splits recovered copper primitives into the default top-facing surface
11
+ * view and de-emphasized buried layers.
12
+ * @param {{ layer?: string, segments: Array<Record<string, number | string>> }[]} polygons
13
+ * @param {{ x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[]} fills
14
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[]} tracks
15
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[]} arcs
16
+ * @param {{ points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[]} regions
17
+ * @returns {{ surface: { polygons: { layer?: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], regions: { points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] }, subsurface: { polygons: { layer?: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], regions: { points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] } }}
18
+ */
19
+ static split(polygons, fills, tracks, arcs, regions) {
20
+ const copperFills = fills.filter((fill) =>
21
+ PcbCopperPrimitiveSplitter.#isCopperLayerId(fill.layerId)
22
+ )
23
+ const copperTracks = tracks.filter((track) =>
24
+ PcbCopperPrimitiveSplitter.#isCopperLayerId(track.layerId)
25
+ )
26
+ const copperArcs = arcs.filter((arc) =>
27
+ PcbCopperPrimitiveSplitter.#isCopperLayerId(arc.layerId)
28
+ )
29
+ const copperRegions = regions.filter((region) =>
30
+ PcbCopperPrimitiveSplitter.#isCopperLayerId(region.layerId)
31
+ )
32
+ const surfaceTrackLayerCode =
33
+ PcbCopperPrimitiveSplitter.#resolveSurfaceLayerCode(copperTracks)
34
+ const surfaceFillLayerCode =
35
+ PcbCopperPrimitiveSplitter.#resolveSurfaceLayerCode(copperFills)
36
+ const surfaceArcLayerCode =
37
+ PcbCopperPrimitiveSplitter.#resolveSurfaceLayerCode(copperArcs)
38
+ const surfaceRegionLayerCode =
39
+ PcbCopperPrimitiveSplitter.#resolveSurfaceLayerCode(copperRegions)
40
+
41
+ return {
42
+ surface: {
43
+ polygons: polygons.filter((polygon) =>
44
+ PcbCopperPrimitiveSplitter.#isSurfacePolygon(polygon)
45
+ ),
46
+ fills: copperFills.filter(
47
+ (fill) => fill.layerCode === surfaceFillLayerCode
48
+ ),
49
+ tracks: copperTracks.filter(
50
+ (track) => track.layerCode === surfaceTrackLayerCode
51
+ ),
52
+ arcs: copperArcs.filter(
53
+ (arc) => arc.layerCode === surfaceArcLayerCode
54
+ ),
55
+ regions: copperRegions.filter(
56
+ (region) => region.layerCode === surfaceRegionLayerCode
57
+ )
58
+ },
59
+ subsurface: {
60
+ polygons: polygons.filter(
61
+ (polygon) =>
62
+ !PcbCopperPrimitiveSplitter.#isSurfacePolygon(polygon)
63
+ ),
64
+ fills: copperFills.filter(
65
+ (fill) => fill.layerCode !== surfaceFillLayerCode
66
+ ),
67
+ tracks: copperTracks.filter(
68
+ (track) => track.layerCode !== surfaceTrackLayerCode
69
+ ),
70
+ arcs: copperArcs.filter(
71
+ (arc) => arc.layerCode !== surfaceArcLayerCode
72
+ ),
73
+ regions: copperRegions.filter(
74
+ (region) => region.layerCode !== surfaceRegionLayerCode
75
+ )
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Returns the default visible layer code from one primitive family.
82
+ * @param {{ layerCode?: number }[]} primitives
83
+ * @returns {number | null}
84
+ */
85
+ static #resolveSurfaceLayerCode(primitives) {
86
+ const layerCodes = primitives
87
+ .map((primitive) => primitive.layerCode)
88
+ .filter((layerCode) => Number.isFinite(layerCode))
89
+ return layerCodes.length ? Math.min(...layerCodes) : null
90
+ }
91
+
92
+ /**
93
+ * Returns true when one polygon belongs to the top-facing copper view.
94
+ * @param {{ layer?: string }} polygon
95
+ * @returns {boolean}
96
+ */
97
+ static #isSurfacePolygon(polygon) {
98
+ return (
99
+ String(polygon.layer || '')
100
+ .trim()
101
+ .toUpperCase() === 'TOP'
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Returns true when one decoded primitive layer belongs to copper.
107
+ * @param {number | undefined} layerId
108
+ * @returns {boolean}
109
+ */
110
+ static #isCopperLayerId(layerId) {
111
+ return Number.isInteger(layerId) && layerId >= 1 && layerId <= 32
112
+ }
113
+ }
@@ -19,10 +19,10 @@ export class PcbEdgeFacingGlyphNormalizer {
19
19
  * Normalizes repeated edge-facing documentation glyphs so their opening
20
20
  * stays on the board edge even when the authored primitive cluster is
21
21
  * mirrored inward.
22
- * @param {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }} footprintPrimitives
22
+ * @param {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], regions?: { points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] }} footprintPrimitives
23
23
  * @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} outline
24
24
  * @param {{ preferMarkers?: boolean }} [options]
25
- * @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }}
25
+ * @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], regions: { points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] }}
26
26
  */
27
27
  static normalize(footprintPrimitives, outline, options = {}) {
28
28
  const normalizedTracks = (footprintPrimitives?.tracks || []).map(
@@ -61,16 +61,17 @@ export class PcbEdgeFacingGlyphNormalizer {
61
61
  return {
62
62
  fills: footprintPrimitives?.fills || [],
63
63
  tracks: normalizedTracks,
64
- arcs: normalizedArcs
64
+ arcs: normalizedArcs,
65
+ regions: footprintPrimitives?.regions || []
65
66
  }
66
67
  }
67
68
 
68
69
  /**
69
70
  * Normalizes glyphs using only the nearest board edge so 3D silkscreen
70
71
  * detail does not overreact to nearby circular markers on other features.
71
- * @param {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }} footprintPrimitives
72
+ * @param {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], regions?: { points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] }} footprintPrimitives
72
73
  * @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} outline
73
- * @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }}
74
+ * @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], regions: { points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[] }}
74
75
  */
75
76
  static normalizeForBoardEdge(footprintPrimitives, outline) {
76
77
  return PcbEdgeFacingGlyphNormalizer.normalize(