altium-toolkit 0.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/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +20 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +22 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/GPL-3.0-or-later.txt +232 -0
- package/NOTICE.md +32 -0
- package/README.md +116 -0
- package/docs/api.md +73 -0
- package/docs/model-format.md +36 -0
- package/docs/testing.md +25 -0
- package/examples/README.md +47 -0
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
- package/examples/arduino-uno/SvgViewportController.mjs +306 -0
- package/examples/arduino-uno/example.mjs +480 -0
- package/examples/arduino-uno/index.html +163 -0
- package/examples/arduino-uno/styles.css +552 -0
- package/examples/server.mjs +212 -0
- package/package.json +53 -0
- package/spec/library-scope.md +32 -0
- package/src/core/BinaryReader.mjs +127 -0
- package/src/core/altium/AltiumLayoutParser.mjs +485 -0
- package/src/core/altium/AltiumParser.mjs +1007 -0
- package/src/core/altium/AsciiRecordParser.mjs +151 -0
- package/src/core/altium/ParserUtils.mjs +173 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
- package/src/core/altium/PcbModelParser.mjs +336 -0
- package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
- package/src/core/altium/PcbStreamExtractor.mjs +210 -0
- package/src/core/altium/PrintableTextDecoder.mjs +156 -0
- package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
- package/src/core/altium/SchematicImageParser.mjs +173 -0
- package/src/core/altium/SchematicJunctionParser.mjs +43 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
- package/src/core/altium/SchematicPinParser.mjs +767 -0
- package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
- package/src/core/altium/SchematicSheetParser.mjs +241 -0
- package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
- package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
- package/src/core/altium/SchematicTextParser.mjs +708 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
- package/src/core/ole/OleCompoundDocument.mjs +439 -0
- package/src/core/ole/OleConstants.mjs +64 -0
- package/src/core/ole/OleDirectoryEntry.mjs +95 -0
- package/src/index.mjs +7 -0
- package/src/parser.mjs +21 -0
- package/src/renderers.mjs +15 -0
- package/src/scene3d.mjs +9 -0
- package/src/styles/altium-renderers.css +358 -0
- package/src/ui/BomTableRenderer.mjs +46 -0
- package/src/ui/PcbArcUtils.mjs +189 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
- package/src/ui/PcbScene3dBuilder.mjs +742 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
- package/src/ui/PcbScene3dPackages.mjs +137 -0
- package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
- package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
- package/src/ui/PcbSvgRenderer.mjs +906 -0
- package/src/ui/SchematicColorResolver.mjs +132 -0
- package/src/ui/SchematicContentLayout.mjs +661 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
- package/src/ui/SchematicImageRenderer.mjs +135 -0
- package/src/ui/SchematicJunctionRenderer.mjs +381 -0
- package/src/ui/SchematicNoteRenderer.mjs +427 -0
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
- package/src/ui/SchematicPortRenderer.mjs +558 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
- package/src/ui/SchematicRegionRenderer.mjs +94 -0
- package/src/ui/SchematicShapeRenderer.mjs +398 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
- package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
- package/src/ui/SchematicSvgRenderer.mjs +756 -0
- package/src/ui/SchematicSvgUtils.mjs +182 -0
- package/src/ui/SchematicTypography.mjs +204 -0
- package/src/workers/altium-parser.worker.mjs +29 -0
|
@@ -0,0 +1,767 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* Helpers for normalized schematic pins, ports, and crosses.
|
|
9
|
+
*/
|
|
10
|
+
export class SchematicPinParser {
|
|
11
|
+
/**
|
|
12
|
+
* Normalizes schematic pin records into drawable pin primitives.
|
|
13
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
14
|
+
* @returns {{ x: number, y: number, length: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, color: string, labelColor: string, labelMode: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex: string }[]}
|
|
15
|
+
*/
|
|
16
|
+
static parseSchematicPins(records) {
|
|
17
|
+
const groups = new Map()
|
|
18
|
+
|
|
19
|
+
for (const record of records) {
|
|
20
|
+
const ownerIndex = ParserUtils.getField(record.fields, 'OwnerIndex')
|
|
21
|
+
const x = ParserUtils.parseNumericField(record.fields, 'Location.X')
|
|
22
|
+
const y = ParserUtils.parseNumericField(record.fields, 'Location.Y')
|
|
23
|
+
const length = ParserUtils.parseNumericField(
|
|
24
|
+
record.fields,
|
|
25
|
+
'PinLength'
|
|
26
|
+
)
|
|
27
|
+
const orientation =
|
|
28
|
+
SchematicPinParser.#inferSchematicPinOrientation(
|
|
29
|
+
ParserUtils.parseNumericField(
|
|
30
|
+
record.fields,
|
|
31
|
+
'PinConglomerate'
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
x === null ||
|
|
37
|
+
y === null ||
|
|
38
|
+
length === null ||
|
|
39
|
+
length <= 0 ||
|
|
40
|
+
!orientation
|
|
41
|
+
) {
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!groups.has(ownerIndex)) {
|
|
46
|
+
groups.set(ownerIndex, [])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
groups.get(ownerIndex).push({
|
|
50
|
+
x,
|
|
51
|
+
y,
|
|
52
|
+
length,
|
|
53
|
+
conglomerate:
|
|
54
|
+
ParserUtils.parseNumericField(
|
|
55
|
+
record.fields,
|
|
56
|
+
'PinConglomerate'
|
|
57
|
+
) || undefined,
|
|
58
|
+
...SchematicPinParser.#parseSchematicPinName(
|
|
59
|
+
ParserUtils.getField(record.fields, 'Name')
|
|
60
|
+
),
|
|
61
|
+
designator: ParserUtils.getField(record.fields, 'Designator'),
|
|
62
|
+
orientation,
|
|
63
|
+
electrical:
|
|
64
|
+
ParserUtils.parseNumericField(
|
|
65
|
+
record.fields,
|
|
66
|
+
'Electrical'
|
|
67
|
+
) || undefined,
|
|
68
|
+
symbolOuter:
|
|
69
|
+
ParserUtils.parseNumericField(
|
|
70
|
+
record.fields,
|
|
71
|
+
'SymBol_Outer'
|
|
72
|
+
) || undefined,
|
|
73
|
+
ownerIndex
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [...groups.values()].flatMap((pins) =>
|
|
78
|
+
SchematicPinParser.#normalizeSchematicPinGroup(pins)
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalizes schematic port records into drawable port boxes.
|
|
84
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
85
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }[]} [lines]
|
|
86
|
+
* @returns {{ x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction: 'left' | 'right' | 'up' | 'down', shape: 'single' | 'double' | 'plain' }[]}
|
|
87
|
+
*/
|
|
88
|
+
static parseSchematicPorts(records, lines = []) {
|
|
89
|
+
return records
|
|
90
|
+
.map((record) => {
|
|
91
|
+
const x =
|
|
92
|
+
ParserUtils.parseNumericField(
|
|
93
|
+
record.fields,
|
|
94
|
+
'Location.X'
|
|
95
|
+
) || 0
|
|
96
|
+
const y =
|
|
97
|
+
ParserUtils.parseNumericField(
|
|
98
|
+
record.fields,
|
|
99
|
+
'Location.Y'
|
|
100
|
+
) || 0
|
|
101
|
+
const width =
|
|
102
|
+
ParserUtils.parseNumericField(record.fields, 'Width') || 40
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
x,
|
|
106
|
+
y,
|
|
107
|
+
width,
|
|
108
|
+
height:
|
|
109
|
+
ParserUtils.parseNumericField(
|
|
110
|
+
record.fields,
|
|
111
|
+
'Height'
|
|
112
|
+
) || 10,
|
|
113
|
+
name: ParserUtils.getField(record.fields, 'Name'),
|
|
114
|
+
fill: ParserUtils.toColor(
|
|
115
|
+
record.fields.AreaColor,
|
|
116
|
+
'#ffe16f'
|
|
117
|
+
),
|
|
118
|
+
color: ParserUtils.toColor(
|
|
119
|
+
record.fields.TextColor || record.fields.Color,
|
|
120
|
+
'#8d2b2b'
|
|
121
|
+
),
|
|
122
|
+
shape: SchematicPinParser.#resolveSchematicPortShape(
|
|
123
|
+
record.fields
|
|
124
|
+
),
|
|
125
|
+
direction:
|
|
126
|
+
SchematicPinParser.#resolveSchematicPortDirection(
|
|
127
|
+
record.fields,
|
|
128
|
+
x,
|
|
129
|
+
y,
|
|
130
|
+
width,
|
|
131
|
+
lines
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.filter((port) => port.name)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolves which horizontal port silhouette Altium requested.
|
|
140
|
+
* @param {Record<string, string | string[]>} fields
|
|
141
|
+
* @returns {'single' | 'double' | 'plain'}
|
|
142
|
+
*/
|
|
143
|
+
static #resolveSchematicPortShape(fields) {
|
|
144
|
+
if (ParserUtils.parseNumericField(fields, 'Style') === 4) {
|
|
145
|
+
return 'single'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ParserUtils.getField(fields, 'IOType') === '3') {
|
|
149
|
+
return 'double'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
!ParserUtils.getField(fields, 'Alignment') &&
|
|
154
|
+
!ParserUtils.getField(fields, 'IOType')
|
|
155
|
+
) {
|
|
156
|
+
return 'plain'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return 'single'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolves which side of an off-sheet port should taper.
|
|
164
|
+
* @param {Record<string, string | string[]>} fields
|
|
165
|
+
* @param {number} x
|
|
166
|
+
* @param {number} y
|
|
167
|
+
* @param {number} width
|
|
168
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
|
|
169
|
+
* @returns {'left' | 'right' | 'up' | 'down'}
|
|
170
|
+
*/
|
|
171
|
+
static #resolveSchematicPortDirection(fields, x, y, width, lines) {
|
|
172
|
+
if (ParserUtils.parseNumericField(fields, 'Style') === 4) {
|
|
173
|
+
const verticalWireSide =
|
|
174
|
+
SchematicPinParser.#findSchematicVerticalPortWireSide(
|
|
175
|
+
x,
|
|
176
|
+
y,
|
|
177
|
+
width,
|
|
178
|
+
lines
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return verticalWireSide || 'up'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const wireSide = SchematicPinParser.#findSchematicPortWireSide(
|
|
185
|
+
x,
|
|
186
|
+
y,
|
|
187
|
+
width,
|
|
188
|
+
lines
|
|
189
|
+
)
|
|
190
|
+
const ioType = ParserUtils.getField(fields, 'IOType')
|
|
191
|
+
|
|
192
|
+
if (wireSide && ioType) {
|
|
193
|
+
return SchematicPinParser.#inferSchematicPortDirectionFromIoType(
|
|
194
|
+
ioType,
|
|
195
|
+
wireSide
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return SchematicPinParser.#inferSchematicPortDirectionFromAlignment(
|
|
200
|
+
ParserUtils.getField(fields, 'Alignment')
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Returns which horizontal side a recovered wire touches for one port.
|
|
206
|
+
* @param {number} x
|
|
207
|
+
* @param {number} y
|
|
208
|
+
* @param {number} width
|
|
209
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
|
|
210
|
+
* @returns {'left' | 'right' | null}
|
|
211
|
+
*/
|
|
212
|
+
static #findSchematicPortWireSide(x, y, width, lines) {
|
|
213
|
+
const tolerance = 0.01
|
|
214
|
+
let touchesLeft = false
|
|
215
|
+
let touchesRight = false
|
|
216
|
+
|
|
217
|
+
for (const line of lines) {
|
|
218
|
+
if (
|
|
219
|
+
Math.abs(Number(line.y1) - y) > tolerance ||
|
|
220
|
+
Math.abs(Number(line.y2) - y) > tolerance
|
|
221
|
+
) {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
touchesLeft =
|
|
226
|
+
touchesLeft ||
|
|
227
|
+
Math.abs(Number(line.x1) - x) <= tolerance ||
|
|
228
|
+
Math.abs(Number(line.x2) - x) <= tolerance
|
|
229
|
+
touchesRight =
|
|
230
|
+
touchesRight ||
|
|
231
|
+
Math.abs(Number(line.x1) - (x + width)) <= tolerance ||
|
|
232
|
+
Math.abs(Number(line.x2) - (x + width)) <= tolerance
|
|
233
|
+
|
|
234
|
+
if (touchesLeft && touchesRight) {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (touchesLeft) {
|
|
240
|
+
return 'left'
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (touchesRight) {
|
|
244
|
+
return 'right'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Returns which vertical side of one style-4 port touches recovered wire
|
|
252
|
+
* geometry. Those ports use `x` as the vertical centerline and `y` as the
|
|
253
|
+
* lower bound of the callout footprint.
|
|
254
|
+
* @param {number} x
|
|
255
|
+
* @param {number} y
|
|
256
|
+
* @param {number} width
|
|
257
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
|
|
258
|
+
* @returns {'up' | 'down' | null}
|
|
259
|
+
*/
|
|
260
|
+
static #findSchematicVerticalPortWireSide(x, y, width, lines) {
|
|
261
|
+
const topPoint = {
|
|
262
|
+
x,
|
|
263
|
+
y: y + width
|
|
264
|
+
}
|
|
265
|
+
const bottomPoint = {
|
|
266
|
+
x,
|
|
267
|
+
y
|
|
268
|
+
}
|
|
269
|
+
let touchesTop = false
|
|
270
|
+
let touchesBottom = false
|
|
271
|
+
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
touchesTop =
|
|
274
|
+
touchesTop ||
|
|
275
|
+
SchematicPinParser.#pointTouchesLine(topPoint, line, 0.01)
|
|
276
|
+
touchesBottom =
|
|
277
|
+
touchesBottom ||
|
|
278
|
+
SchematicPinParser.#pointTouchesLine(bottomPoint, line, 0.01)
|
|
279
|
+
|
|
280
|
+
if (touchesTop && touchesBottom) {
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (touchesTop) {
|
|
286
|
+
return 'up'
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (touchesBottom) {
|
|
290
|
+
return 'down'
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Infers the tapered side from port IO type plus attached wire side.
|
|
298
|
+
* @param {string} ioType
|
|
299
|
+
* @param {'left' | 'right'} wireSide
|
|
300
|
+
* @returns {'left' | 'right'}
|
|
301
|
+
*/
|
|
302
|
+
static #inferSchematicPortDirectionFromIoType(ioType, wireSide) {
|
|
303
|
+
if (String(ioType) === '2') {
|
|
304
|
+
return wireSide
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return wireSide === 'left' ? 'right' : 'left'
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Infers which side of an off-sheet port should taper from legacy
|
|
312
|
+
* alignment data when no better connectivity clue is available.
|
|
313
|
+
* @param {string} alignment
|
|
314
|
+
* @returns {'left' | 'right'}
|
|
315
|
+
*/
|
|
316
|
+
static #inferSchematicPortDirectionFromAlignment(alignment) {
|
|
317
|
+
return String(alignment || '') === '2' ? 'right' : 'left'
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Returns true when a point lands on one line endpoint or on an
|
|
322
|
+
* axis-aligned segment within a small tolerance.
|
|
323
|
+
* @param {{ x: number, y: number }} point
|
|
324
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} line
|
|
325
|
+
* @param {number} tolerance
|
|
326
|
+
* @returns {boolean}
|
|
327
|
+
*/
|
|
328
|
+
static #pointTouchesLine(point, line, tolerance) {
|
|
329
|
+
const effectiveTolerance = Math.max(Number(tolerance || 0.01), 0.01)
|
|
330
|
+
const touchesStart =
|
|
331
|
+
Math.abs(Number(line.x1) - point.x) <= effectiveTolerance &&
|
|
332
|
+
Math.abs(Number(line.y1) - point.y) <= effectiveTolerance
|
|
333
|
+
const touchesEnd =
|
|
334
|
+
Math.abs(Number(line.x2) - point.x) <= effectiveTolerance &&
|
|
335
|
+
Math.abs(Number(line.y2) - point.y) <= effectiveTolerance
|
|
336
|
+
|
|
337
|
+
if (touchesStart || touchesEnd) {
|
|
338
|
+
return true
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const minX =
|
|
342
|
+
Math.min(Number(line.x1), Number(line.x2)) - effectiveTolerance
|
|
343
|
+
const maxX =
|
|
344
|
+
Math.max(Number(line.x1), Number(line.x2)) + effectiveTolerance
|
|
345
|
+
const minY =
|
|
346
|
+
Math.min(Number(line.y1), Number(line.y2)) - effectiveTolerance
|
|
347
|
+
const maxY =
|
|
348
|
+
Math.max(Number(line.y1), Number(line.y2)) + effectiveTolerance
|
|
349
|
+
|
|
350
|
+
if (
|
|
351
|
+
Math.abs(Number(line.x1) - Number(line.x2)) <= effectiveTolerance &&
|
|
352
|
+
Math.abs(point.x - Number(line.x1)) <= effectiveTolerance &&
|
|
353
|
+
point.y >= minY &&
|
|
354
|
+
point.y <= maxY
|
|
355
|
+
) {
|
|
356
|
+
return true
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
Math.abs(Number(line.y1) - Number(line.y2)) <= effectiveTolerance &&
|
|
361
|
+
Math.abs(point.y - Number(line.y1)) <= effectiveTolerance &&
|
|
362
|
+
point.x >= minX &&
|
|
363
|
+
point.x <= maxX
|
|
364
|
+
) {
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Normalizes no-connect crosses from schematic records.
|
|
373
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
374
|
+
* @returns {{ x: number, y: number, size: number, color: string }[]}
|
|
375
|
+
*/
|
|
376
|
+
static parseSchematicCrosses(records) {
|
|
377
|
+
return records
|
|
378
|
+
.map((record) => ({
|
|
379
|
+
x:
|
|
380
|
+
ParserUtils.parseNumericField(
|
|
381
|
+
record.fields,
|
|
382
|
+
'Location.X'
|
|
383
|
+
) || 0,
|
|
384
|
+
y:
|
|
385
|
+
ParserUtils.parseNumericField(
|
|
386
|
+
record.fields,
|
|
387
|
+
'Location.Y'
|
|
388
|
+
) || 0,
|
|
389
|
+
size: 6,
|
|
390
|
+
color: ParserUtils.toColor(record.fields.Color, '#ff0000')
|
|
391
|
+
}))
|
|
392
|
+
.filter((cross) => cross.x || cross.y)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Expands a schematic polyline record into drawable line segments.
|
|
397
|
+
* @param {Record<string, string | string[]>} fields
|
|
398
|
+
* @param {{ isBus?: boolean }} [options]
|
|
399
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle: number, isBus?: boolean }[]}
|
|
400
|
+
*/
|
|
401
|
+
static parseSchematicPolyline(fields, options = {}) {
|
|
402
|
+
const locationCount = ParserUtils.parseNumericField(
|
|
403
|
+
fields,
|
|
404
|
+
'LocationCount'
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
if (locationCount === null || locationCount < 2) {
|
|
408
|
+
return []
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const points = []
|
|
412
|
+
|
|
413
|
+
for (let index = 1; index <= locationCount; index += 1) {
|
|
414
|
+
const x = ParserUtils.parseNumericField(fields, 'X' + index)
|
|
415
|
+
const y = ParserUtils.parseNumericField(fields, 'Y' + index)
|
|
416
|
+
|
|
417
|
+
if (x === null || y === null) {
|
|
418
|
+
break
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
points.push({ x, y })
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const segments = []
|
|
425
|
+
const lineStyle =
|
|
426
|
+
ParserUtils.parseNumericField(fields, 'LineStyle') || 0
|
|
427
|
+
|
|
428
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
429
|
+
const previous = points[index - 1]
|
|
430
|
+
const current = points[index]
|
|
431
|
+
|
|
432
|
+
segments.push({
|
|
433
|
+
x1: previous.x,
|
|
434
|
+
y1: previous.y,
|
|
435
|
+
x2: current.x,
|
|
436
|
+
y2: current.y,
|
|
437
|
+
color: ParserUtils.toColor(fields.Color, '#a44a1b'),
|
|
438
|
+
width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
|
|
439
|
+
lineStyle,
|
|
440
|
+
isBus: options.isBus === true ? true : undefined
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return segments
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Expands a schematic polygon record into closed drawable line segments.
|
|
449
|
+
* @param {Record<string, string | string[]>} fields
|
|
450
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle: number }[]}
|
|
451
|
+
*/
|
|
452
|
+
static parseSchematicPolygon(fields) {
|
|
453
|
+
const locationCount = ParserUtils.parseNumericField(
|
|
454
|
+
fields,
|
|
455
|
+
'LocationCount'
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
if (locationCount === null || locationCount < 2) {
|
|
459
|
+
return []
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const points = []
|
|
463
|
+
|
|
464
|
+
for (let index = 1; index <= locationCount; index += 1) {
|
|
465
|
+
const x = ParserUtils.parseNumericField(fields, 'X' + index)
|
|
466
|
+
const y = ParserUtils.parseNumericField(fields, 'Y' + index)
|
|
467
|
+
|
|
468
|
+
if (x === null || y === null) {
|
|
469
|
+
break
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
points.push({ x, y })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (points.length < 2) {
|
|
476
|
+
return []
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const segments = []
|
|
480
|
+
const lineStyle =
|
|
481
|
+
ParserUtils.parseNumericField(fields, 'LineStyle') || 0
|
|
482
|
+
|
|
483
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
484
|
+
const previous = points[index - 1]
|
|
485
|
+
const current = points[index]
|
|
486
|
+
|
|
487
|
+
segments.push({
|
|
488
|
+
x1: previous.x,
|
|
489
|
+
y1: previous.y,
|
|
490
|
+
x2: current.x,
|
|
491
|
+
y2: current.y,
|
|
492
|
+
color: ParserUtils.toColor(fields.Color, '#a44a1b'),
|
|
493
|
+
width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
|
|
494
|
+
lineStyle
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const firstPoint = points[0]
|
|
499
|
+
const lastPoint = points[points.length - 1]
|
|
500
|
+
|
|
501
|
+
segments.push({
|
|
502
|
+
x1: lastPoint.x,
|
|
503
|
+
y1: lastPoint.y,
|
|
504
|
+
x2: firstPoint.x,
|
|
505
|
+
y2: firstPoint.y,
|
|
506
|
+
color: ParserUtils.toColor(fields.Color, '#a44a1b'),
|
|
507
|
+
width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
|
|
508
|
+
lineStyle
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
return segments
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Deduces the visible pins for one schematic symbol owner.
|
|
516
|
+
* @param {{ x: number, y: number, length: number, conglomerate?: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, ownerIndex: string }[]} pins
|
|
517
|
+
* @returns {{ x: number, y: number, length: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, color: string, labelColor: string, labelMode: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex: string }[]}
|
|
518
|
+
*/
|
|
519
|
+
static #normalizeSchematicPinGroup(pins) {
|
|
520
|
+
const deduped = SchematicPinParser.#dedupeSchematicPins(pins)
|
|
521
|
+
const names = [
|
|
522
|
+
...new Set(deduped.map((pin) => pin.name).filter(Boolean))
|
|
523
|
+
]
|
|
524
|
+
const orientationCount = new Set(deduped.map((pin) => pin.orientation))
|
|
525
|
+
.size
|
|
526
|
+
const allPassive = names.every((name) =>
|
|
527
|
+
SchematicPinParser.#isPassivePinName(name)
|
|
528
|
+
)
|
|
529
|
+
const semanticNames = names.filter(
|
|
530
|
+
(name) => !SchematicPinParser.#isPassivePinName(name)
|
|
531
|
+
)
|
|
532
|
+
const allNumberedPins =
|
|
533
|
+
deduped.length > 0 &&
|
|
534
|
+
deduped.every(
|
|
535
|
+
(pin) =>
|
|
536
|
+
/^\d+$/.test(String(pin.designator || '').trim()) &&
|
|
537
|
+
(!pin.name || /^\d+$/.test(String(pin.name || '').trim()))
|
|
538
|
+
)
|
|
539
|
+
let labelMode = 'name-and-number'
|
|
540
|
+
|
|
541
|
+
if (SchematicPinParser.#isDenseTwoSidedHorizontal4850Family(deduped)) {
|
|
542
|
+
labelMode = 'number-only'
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (allPassive && orientationCount > 2) {
|
|
546
|
+
// Keep dense multi-side connector symbols whose contacts are only
|
|
547
|
+
// identified by numbers; dropping them loses both pin numbers and
|
|
548
|
+
// any power-port attachment geometry recovered from those pins.
|
|
549
|
+
if (deduped.length > 4 && !allNumberedPins) {
|
|
550
|
+
return []
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
labelMode = 'number-only'
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (allPassive && deduped.length <= 2) {
|
|
557
|
+
labelMode = SchematicPinParser.#isCanonicalPassiveTwoPinGroup(
|
|
558
|
+
deduped
|
|
559
|
+
)
|
|
560
|
+
? 'hidden'
|
|
561
|
+
: 'number-only'
|
|
562
|
+
} else if (!semanticNames.length && orientationCount <= 2) {
|
|
563
|
+
labelMode = 'number-only'
|
|
564
|
+
} else if (
|
|
565
|
+
semanticNames.length >= Math.max(names.length - 1, 3) &&
|
|
566
|
+
orientationCount <= 2 &&
|
|
567
|
+
deduped.length <= 4
|
|
568
|
+
) {
|
|
569
|
+
labelMode = 'name-only'
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return deduped.map(({ conglomerate, ...pin }) => ({
|
|
573
|
+
...pin,
|
|
574
|
+
color: '#0000ff',
|
|
575
|
+
labelColor: '#1f1f1f',
|
|
576
|
+
labelMode
|
|
577
|
+
}))
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Returns true when one passive two-pin symbol uses the ordinary 1/2 pin
|
|
582
|
+
* numbering that should stay hidden for simple resistor-like parts.
|
|
583
|
+
* @param {{ designator: string }[]} pins
|
|
584
|
+
* @returns {boolean}
|
|
585
|
+
*/
|
|
586
|
+
static #isCanonicalPassiveTwoPinGroup(pins) {
|
|
587
|
+
if (pins.length !== 2) {
|
|
588
|
+
return false
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const designators = pins
|
|
592
|
+
.map((pin) => String(pin.designator || '').trim())
|
|
593
|
+
.sort((left, right) => Number(left) - Number(right))
|
|
594
|
+
|
|
595
|
+
return designators[0] === '1' && designators[1] === '2'
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Returns true when one owner uses the dense two-sided horizontal 48/50
|
|
600
|
+
* pin family whose semantic names belong to the owner-drawn symbol body
|
|
601
|
+
* rather than to visible external pin labels.
|
|
602
|
+
* @param {{ conglomerate?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
603
|
+
* @returns {boolean}
|
|
604
|
+
*/
|
|
605
|
+
static #isDenseTwoSidedHorizontal4850Family(pins) {
|
|
606
|
+
if (pins.length < 6) {
|
|
607
|
+
return false
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (
|
|
611
|
+
pins.some(
|
|
612
|
+
(pin) =>
|
|
613
|
+
pin.orientation !== 'left' && pin.orientation !== 'right'
|
|
614
|
+
)
|
|
615
|
+
) {
|
|
616
|
+
return false
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const conglomerates = new Set(
|
|
620
|
+
pins.map((pin) => Number(pin.conglomerate || 0))
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
return (
|
|
624
|
+
conglomerates.size > 0 &&
|
|
625
|
+
[...conglomerates].every(
|
|
626
|
+
(conglomerate) => conglomerate === 48 || conglomerate === 50
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Removes duplicate pin records emitted for alternate display modes.
|
|
633
|
+
* @param {{ x: number, y: number, length: number, conglomerate?: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, ownerIndex: string }[]} pins
|
|
634
|
+
* @returns {{ x: number, y: number, length: number, conglomerate?: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, ownerIndex: string }[]}
|
|
635
|
+
*/
|
|
636
|
+
static #dedupeSchematicPins(pins) {
|
|
637
|
+
const seen = new Set()
|
|
638
|
+
const deduped = []
|
|
639
|
+
|
|
640
|
+
for (const pin of pins) {
|
|
641
|
+
const key = [
|
|
642
|
+
pin.ownerIndex,
|
|
643
|
+
pin.x,
|
|
644
|
+
pin.y,
|
|
645
|
+
pin.length,
|
|
646
|
+
pin.name,
|
|
647
|
+
pin.designator,
|
|
648
|
+
pin.orientation,
|
|
649
|
+
pin.electrical,
|
|
650
|
+
pin.symbolOuter || '',
|
|
651
|
+
SchematicPinParser.#serializeSchematicPinNameSegments(
|
|
652
|
+
pin.nameSegments
|
|
653
|
+
)
|
|
654
|
+
].join('::')
|
|
655
|
+
|
|
656
|
+
if (seen.has(key)) continue
|
|
657
|
+
|
|
658
|
+
seen.add(key)
|
|
659
|
+
deduped.push(pin)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return deduped
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Decodes Altium backslash suffix markers into visible pin text and
|
|
667
|
+
* overline runs for active-low labels.
|
|
668
|
+
* @param {string} name
|
|
669
|
+
* @returns {{ name: string, nameSegments?: { text: string, overline: boolean }[] }}
|
|
670
|
+
*/
|
|
671
|
+
static #parseSchematicPinName(name) {
|
|
672
|
+
const characters = []
|
|
673
|
+
|
|
674
|
+
for (const character of String(name || '').trim()) {
|
|
675
|
+
if (character === '\\') {
|
|
676
|
+
const previousCharacter = characters.at(-1)
|
|
677
|
+
if (previousCharacter) {
|
|
678
|
+
previousCharacter.overline = true
|
|
679
|
+
}
|
|
680
|
+
continue
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
characters.push({
|
|
684
|
+
text: character,
|
|
685
|
+
overline: false
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const normalizedName = characters
|
|
690
|
+
.map((character) => character.text)
|
|
691
|
+
.join('')
|
|
692
|
+
const nameSegments = []
|
|
693
|
+
|
|
694
|
+
for (const character of characters) {
|
|
695
|
+
const previousSegment = nameSegments.at(-1)
|
|
696
|
+
if (
|
|
697
|
+
previousSegment &&
|
|
698
|
+
previousSegment.overline === character.overline
|
|
699
|
+
) {
|
|
700
|
+
previousSegment.text += character.text
|
|
701
|
+
continue
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
nameSegments.push({
|
|
705
|
+
text: character.text,
|
|
706
|
+
overline: character.overline
|
|
707
|
+
})
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
name: normalizedName,
|
|
712
|
+
nameSegments: nameSegments.some((segment) => segment.overline)
|
|
713
|
+
? nameSegments
|
|
714
|
+
: undefined
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Serializes overline runs into a dedupe-safe signature.
|
|
720
|
+
* @param {{ text: string, overline: boolean }[] | undefined} nameSegments
|
|
721
|
+
* @returns {string}
|
|
722
|
+
*/
|
|
723
|
+
static #serializeSchematicPinNameSegments(nameSegments) {
|
|
724
|
+
return (nameSegments || [])
|
|
725
|
+
.map(
|
|
726
|
+
(segment) => (segment.overline ? '1' : '0') + ':' + segment.text
|
|
727
|
+
)
|
|
728
|
+
.join('|')
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Returns true when a pin name looks like a passive-symbol terminal.
|
|
733
|
+
* @param {string} name
|
|
734
|
+
* @returns {boolean}
|
|
735
|
+
*/
|
|
736
|
+
static #isPassivePinName(name) {
|
|
737
|
+
return /^(\d+|[AK])$/i.test(String(name || '').trim())
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Maps Altium pin conglomerate flags into a side orientation.
|
|
742
|
+
* @param {number | null} conglomerate
|
|
743
|
+
* @returns {'left' | 'right' | 'top' | 'bottom' | null}
|
|
744
|
+
*/
|
|
745
|
+
static #inferSchematicPinOrientation(conglomerate) {
|
|
746
|
+
switch (conglomerate) {
|
|
747
|
+
case 34:
|
|
748
|
+
case 50:
|
|
749
|
+
case 58:
|
|
750
|
+
return 'left'
|
|
751
|
+
case 32:
|
|
752
|
+
case 48:
|
|
753
|
+
case 56:
|
|
754
|
+
return 'right'
|
|
755
|
+
case 33:
|
|
756
|
+
case 49:
|
|
757
|
+
case 57:
|
|
758
|
+
return 'top'
|
|
759
|
+
case 35:
|
|
760
|
+
case 51:
|
|
761
|
+
case 59:
|
|
762
|
+
return 'bottom'
|
|
763
|
+
default:
|
|
764
|
+
return null
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|