altium-toolkit 0.1.23 → 1.0.2

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.
@@ -0,0 +1,180 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ComponentGrouping } from './ComponentGrouping.mjs'
6
+
7
+ /**
8
+ * Builds compact query netlists from normalized toolkit document models.
9
+ */
10
+ export class QueryNetlistBuilder {
11
+ /**
12
+ * Builds the query netlist for one document model.
13
+ * @param {object} documentModel Document model.
14
+ * @returns {{ nets: object, components: object }}
15
+ */
16
+ static build(documentModel) {
17
+ const components =
18
+ QueryNetlistBuilder.#buildComponentDetails(documentModel)
19
+ const nets = {}
20
+
21
+ for (const net of documentModel?.schematic?.nets || []) {
22
+ const netName = String(net?.name || '').trim()
23
+ if (!netName) continue
24
+
25
+ for (const pin of net.pins || []) {
26
+ const refdes = QueryNetlistBuilder.#resolvePinRefdes(
27
+ pin,
28
+ components
29
+ )
30
+ const pinNumber = QueryNetlistBuilder.#resolvePinNumber(pin)
31
+ if (!refdes || !pinNumber) continue
32
+
33
+ nets[netName] ||= {}
34
+ const existing = nets[netName][refdes]
35
+ nets[netName][refdes] = QueryNetlistBuilder.#appendPin(
36
+ existing,
37
+ pinNumber
38
+ )
39
+ components[refdes] ||= { pins: {} }
40
+ components[refdes].pins[pinNumber] =
41
+ QueryNetlistBuilder.#pinEntry(pinNumber, pin?.name, netName)
42
+ }
43
+ }
44
+
45
+ return { nets, components }
46
+ }
47
+
48
+ /**
49
+ * Builds component metadata from schematic, PCB, and BOM records.
50
+ * @param {object} documentModel Document model.
51
+ * @returns {object}
52
+ */
53
+ static #buildComponentDetails(documentModel) {
54
+ const components = {}
55
+
56
+ for (const component of documentModel?.schematic?.components || []) {
57
+ const refdes = String(component?.designator || '').trim()
58
+ if (!refdes) continue
59
+ components[refdes] = {
60
+ ...components[refdes],
61
+ value: component.value || components[refdes]?.value,
62
+ description:
63
+ component.description || components[refdes]?.description,
64
+ comment: component.comment || components[refdes]?.comment,
65
+ ownerIndex:
66
+ component.ownerIndex || components[refdes]?.ownerIndex,
67
+ dns:
68
+ component.dns ||
69
+ component.excludeFromBom ||
70
+ components[refdes]?.dns,
71
+ excludeFromBom:
72
+ component.excludeFromBom ||
73
+ components[refdes]?.excludeFromBom,
74
+ pins: components[refdes]?.pins || {}
75
+ }
76
+ }
77
+
78
+ for (const component of documentModel?.pcb?.components || []) {
79
+ const refdes = String(component?.designator || '').trim()
80
+ if (!refdes) continue
81
+ components[refdes] = {
82
+ ...components[refdes],
83
+ description:
84
+ component.description ||
85
+ component.pattern ||
86
+ components[refdes]?.description,
87
+ value: component.value || components[refdes]?.value,
88
+ comment: component.comment || components[refdes]?.comment,
89
+ pins: components[refdes]?.pins || {}
90
+ }
91
+ }
92
+
93
+ for (const row of documentModel?.bom || []) {
94
+ for (const refdes of row.designators || []) {
95
+ const normalizedRefdes = String(refdes || '').trim()
96
+ if (!normalizedRefdes) continue
97
+ components[normalizedRefdes] = {
98
+ ...components[normalizedRefdes],
99
+ mpn: row.pattern || components[normalizedRefdes]?.mpn,
100
+ description:
101
+ row.source || components[normalizedRefdes]?.description,
102
+ value: row.value || components[normalizedRefdes]?.value,
103
+ pins: components[normalizedRefdes]?.pins || {}
104
+ }
105
+ }
106
+ }
107
+
108
+ return components
109
+ }
110
+
111
+ /**
112
+ * Resolves a pin's owning reference designator.
113
+ * @param {object} pin Net pin.
114
+ * @param {object} components Component details.
115
+ * @returns {string}
116
+ */
117
+ static #resolvePinRefdes(pin, components) {
118
+ const direct = String(
119
+ pin?.refdes ||
120
+ pin?.componentRefdes ||
121
+ pin?.componentDesignator ||
122
+ pin?.ownerDesignator ||
123
+ ''
124
+ ).trim()
125
+ if (direct) return direct
126
+
127
+ const ownerIndex = String(pin?.ownerIndex || '').trim()
128
+ if (!ownerIndex) return ''
129
+
130
+ return (
131
+ Object.entries(components).find(([, component]) => {
132
+ return String(component.ownerIndex || '') === ownerIndex
133
+ })?.[0] || ''
134
+ )
135
+ }
136
+
137
+ /**
138
+ * Resolves a pin number.
139
+ * @param {object} pin Net pin.
140
+ * @returns {string}
141
+ */
142
+ static #resolvePinNumber(pin) {
143
+ return String(
144
+ pin?.pinNumber || pin?.number || pin?.designator || ''
145
+ ).trim()
146
+ }
147
+
148
+ /**
149
+ * Appends a pin to a compact net connection value.
150
+ * @param {string | string[] | undefined} existing Existing pins.
151
+ * @param {string} pinNumber Pin number.
152
+ * @returns {string | string[]}
153
+ */
154
+ static #appendPin(existing, pinNumber) {
155
+ if (!existing) return pinNumber
156
+ const pins = Array.isArray(existing) ? existing : [existing]
157
+ if (!pins.includes(pinNumber)) {
158
+ pins.push(pinNumber)
159
+ }
160
+ return ComponentGrouping.compactArray(
161
+ pins.sort(ComponentGrouping.naturalSort)
162
+ )
163
+ }
164
+
165
+ /**
166
+ * Builds a compact pin entry.
167
+ * @param {string} pinNumber Pin number.
168
+ * @param {string | undefined} pinName Pin name.
169
+ * @param {string} netName Net name.
170
+ * @returns {string | { name: string, net: string }}
171
+ */
172
+ static #pinEntry(pinNumber, pinName, netName) {
173
+ const normalizedName = String(pinName || '').trim()
174
+ if (normalizedName && normalizedName !== pinNumber) {
175
+ return { name: normalizedName, net: netName }
176
+ }
177
+
178
+ return netName
179
+ }
180
+ }
@@ -0,0 +1,89 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Regex helpers for netlist query tools.
7
+ */
8
+ export class RegexPattern {
9
+ /**
10
+ * Parses a user regex pattern into a JavaScript RegExp.
11
+ * @param {string} pattern User-provided pattern.
12
+ * @param {string} [flags] RegExp flags.
13
+ * @returns {{ regex: RegExp } | { error: string }}
14
+ */
15
+ static parse(pattern, flags = 'i') {
16
+ const normalizedPattern = RegexPattern.#normalizeInlineFlags(pattern)
17
+ const normalizedFlags = RegexPattern.#normalizeFlags(
18
+ flags,
19
+ normalizedPattern.forceIgnoreCase
20
+ )
21
+
22
+ try {
23
+ return {
24
+ regex: new RegExp(normalizedPattern.pattern, normalizedFlags)
25
+ }
26
+ } catch (error) {
27
+ return {
28
+ error:
29
+ 'Invalid regex pattern ' +
30
+ JSON.stringify(String(pattern || '')) +
31
+ ': ' +
32
+ (error instanceof Error ? error.message : String(error))
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Returns true when a pattern matches every candidate value.
39
+ * @param {string} pattern User-provided pattern.
40
+ * @param {string[]} candidates Candidate values.
41
+ * @returns {boolean}
42
+ */
43
+ static rejectsBroadMatch(pattern, candidates) {
44
+ const parsed = RegexPattern.parse(pattern)
45
+ if (parsed.error || !Array.isArray(candidates) || !candidates.length) {
46
+ return false
47
+ }
48
+
49
+ return candidates.every((candidate) => {
50
+ parsed.regex.lastIndex = 0
51
+ return parsed.regex.test(String(candidate || ''))
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Normalizes a leading `(?i)` flag into JavaScript RegExp flags.
57
+ * @param {string} pattern User-provided pattern.
58
+ * @returns {{ pattern: string, forceIgnoreCase: boolean }}
59
+ */
60
+ static #normalizeInlineFlags(pattern) {
61
+ const source = String(pattern || '')
62
+ if (source.startsWith('(?i)')) {
63
+ return {
64
+ pattern: source.slice(4),
65
+ forceIgnoreCase: true
66
+ }
67
+ }
68
+
69
+ return {
70
+ pattern: source,
71
+ forceIgnoreCase: false
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Adds the ignore-case flag when requested.
77
+ * @param {string} flags RegExp flags.
78
+ * @param {boolean} forceIgnoreCase Whether to force `i`.
79
+ * @returns {string}
80
+ */
81
+ static #normalizeFlags(flags, forceIgnoreCase) {
82
+ const uniqueFlags = new Set(String(flags || '').split(''))
83
+ if (forceIgnoreCase) {
84
+ uniqueFlags.add('i')
85
+ }
86
+
87
+ return [...uniqueFlags].join('')
88
+ }
89
+ }
@@ -0,0 +1,12 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ export { RegexPattern } from './core/netlist-query/RegexPattern.mjs'
6
+ export {
7
+ ComponentGrouping,
8
+ MPN_MISSING_NOTE
9
+ } from './core/netlist-query/ComponentGrouping.mjs'
10
+ export { CircuitTraversal } from './core/netlist-query/CircuitTraversal.mjs'
11
+ export { QueryNetlistBuilder } from './core/netlist-query/QueryNetlistBuilder.mjs'
12
+ export { LoadedDesignNetlistService } from './core/netlist-query/LoadedDesignNetlistService.mjs'
package/src/parser.mjs CHANGED
@@ -11,6 +11,8 @@ 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
13
  export { NormalizedModelSchema } from './core/altium/NormalizedModelSchema.mjs'
14
+ export { CircuitJsonModelSchema } from './core/circuit-json/CircuitJsonModelSchema.mjs'
15
+ export { CircuitJsonModelAdapter } from './core/circuit-json/CircuitJsonModelAdapter.mjs'
14
16
  export { PcbBinaryPrimitiveParser } from './core/altium/PcbBinaryPrimitiveParser.mjs'
15
17
  export { PcbBoardRegionSemanticsParser } from './core/altium/PcbBoardRegionSemanticsParser.mjs'
16
18
  export { PcbComponentPrimitiveIndexer } from './core/altium/PcbComponentPrimitiveIndexer.mjs'
@@ -141,6 +141,7 @@
141
141
  --pcb-via-hole-fill: #0f746c;
142
142
  --pcb-footprint-fill: rgba(247, 230, 117, 0.14);
143
143
  --pcb-footprint-track-color: rgba(237, 172, 36, 1);
144
+ --pcb-text-knockout-fill: rgba(248, 246, 239, 0.96);
144
145
  --pcb-component-top-fill: rgba(244, 219, 198, 0.92);
145
146
  --pcb-component-bottom-fill: rgba(15, 116, 108, 0.84);
146
147
  --pcb-component-stroke: rgba(110, 64, 38, 0.28);
@@ -178,6 +179,8 @@
178
179
  fill: var(--pcb-board-fill);
179
180
  stroke: var(--pcb-board-stroke);
180
181
  stroke-width: 18;
182
+ stroke-linecap: round;
183
+ stroke-linejoin: round;
181
184
  }
182
185
 
183
186
  .board-outline--stroke {
@@ -252,7 +255,7 @@
252
255
  }
253
256
 
254
257
  .pcb-footprint-region {
255
- fill: var(--pcb-footprint-track-color);
258
+ fill: var(--pcb-footprint-region-fill, var(--pcb-footprint-track-color));
256
259
  }
257
260
 
258
261
  .pcb-footprint-track,
@@ -278,6 +281,14 @@
278
281
  pointer-events: none;
279
282
  }
280
283
 
284
+ .pcb-text__knockout-fill {
285
+ fill: var(--pcb-text-knockout-fill);
286
+ }
287
+
288
+ .pcb-text__knockout-glyphs {
289
+ fill: #000;
290
+ }
291
+
281
292
  .bom-panel {
282
293
  display: grid;
283
294
  gap: 1rem;
@@ -0,0 +1,130 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Detects native overlay artwork that already contains text knockout holes.
7
+ */
8
+ export class PcbNativeTextKnockoutDetector {
9
+ static #DENSE_OVERLAY_MIN_REGION_AREA_RATIO = 0.2
10
+ static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
11
+
12
+ /**
13
+ * Returns true when side-resolved overlay primitives carry native text
14
+ * knockouts, so source inverted TrueType labels would duplicate artwork.
15
+ * @param {{ fills?: object[], regions?: object[], tracks?: object[], arcs?: object[] }} primitives Side-resolved overlay primitives.
16
+ * @param {{ widthMil?: number, heightMil?: number }} board Board bounds.
17
+ * @returns {boolean}
18
+ */
19
+ static hasNativeTextKnockouts(primitives, board) {
20
+ const fills = [
21
+ ...(Array.isArray(primitives?.fills) ? primitives.fills : []),
22
+ ...(Array.isArray(primitives?.regions) ? primitives.regions : [])
23
+ ]
24
+
25
+ return (
26
+ PcbNativeTextKnockoutDetector.#isDenseOverlayArtwork(
27
+ {
28
+ fills,
29
+ tracks: primitives?.tracks,
30
+ arcs: primitives?.arcs
31
+ },
32
+ board
33
+ ) &&
34
+ fills.some(
35
+ (fill) => Array.isArray(fill?.holes) && fill.holes.length > 0
36
+ )
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Detects dense overlay artwork from structural density.
42
+ * @param {{ fills?: object[], tracks?: object[], arcs?: object[] }} side Side primitives.
43
+ * @param {{ widthMil?: number, heightMil?: number }} board Board bounds.
44
+ * @returns {boolean}
45
+ */
46
+ static #isDenseOverlayArtwork(side, board) {
47
+ const strokeCount =
48
+ (Array.isArray(side?.tracks) ? side.tracks.length : 0) +
49
+ (Array.isArray(side?.arcs) ? side.arcs.length : 0)
50
+
51
+ return (
52
+ strokeCount >=
53
+ PcbNativeTextKnockoutDetector.#DENSE_OVERLAY_MIN_TRACK_COUNT &&
54
+ PcbNativeTextKnockoutDetector.#maxFillAreaRatio(
55
+ side?.fills,
56
+ board
57
+ ) >=
58
+ PcbNativeTextKnockoutDetector
59
+ .#DENSE_OVERLAY_MIN_REGION_AREA_RATIO
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Resolves the largest fill-to-board bounding-box area ratio.
65
+ * @param {object[] | undefined} fills Fill primitives.
66
+ * @param {{ widthMil?: number, heightMil?: number }} board Board bounds.
67
+ * @returns {number}
68
+ */
69
+ static #maxFillAreaRatio(fills, board) {
70
+ const boardArea =
71
+ Math.max(Number(board?.widthMil || 0), 0) *
72
+ Math.max(Number(board?.heightMil || 0), 0)
73
+ if (!boardArea) {
74
+ return 0
75
+ }
76
+
77
+ return (Array.isArray(fills) ? fills : []).reduce((maxRatio, fill) => {
78
+ const bounds =
79
+ PcbNativeTextKnockoutDetector.#resolveFillBounds(fill)
80
+ if (!bounds) {
81
+ return maxRatio
82
+ }
83
+
84
+ const fillArea =
85
+ Math.max(bounds.maxX - bounds.minX, 0) *
86
+ Math.max(bounds.maxY - bounds.minY, 0)
87
+
88
+ return Math.max(maxRatio, fillArea / boardArea)
89
+ }, 0)
90
+ }
91
+
92
+ /**
93
+ * Resolves rough authored bounds for one rectangular or polygon fill.
94
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number, points?: { x?: number, y?: number }[] }} fill Fill primitive.
95
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
96
+ */
97
+ static #resolveFillBounds(fill) {
98
+ const points = Array.isArray(fill?.points)
99
+ ? fill.points
100
+ .map((point) => ({
101
+ x: Number(point?.x),
102
+ y: Number(point?.y)
103
+ }))
104
+ .filter(
105
+ (point) =>
106
+ Number.isFinite(point.x) && Number.isFinite(point.y)
107
+ )
108
+ : [
109
+ { x: Number(fill?.x1), y: Number(fill?.y1) },
110
+ { x: Number(fill?.x2), y: Number(fill?.y2) }
111
+ ].filter(
112
+ (point) =>
113
+ Number.isFinite(point.x) && Number.isFinite(point.y)
114
+ )
115
+
116
+ if (points.length < 2) {
117
+ return null
118
+ }
119
+
120
+ const xs = points.map((point) => point.x)
121
+ const ys = points.map((point) => point.y)
122
+
123
+ return {
124
+ minX: Math.min(...xs),
125
+ minY: Math.min(...ys),
126
+ maxX: Math.max(...xs),
127
+ maxY: Math.max(...ys)
128
+ }
129
+ }
130
+ }