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,564 @@
|
|
|
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 { getField, parseBoolean, parseNumericField } = ParserUtils
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves which multipart symbol section is visible for one schematic owner.
|
|
11
|
+
*/
|
|
12
|
+
export class SchematicMultipartOwnerMatcher {
|
|
13
|
+
/**
|
|
14
|
+
* Matches multipart owner indexes to the currently visible part id stored
|
|
15
|
+
* on their component placements.
|
|
16
|
+
* @param {{ raw: string, fields: Record<string, string | string[]> }[]} records
|
|
17
|
+
* @param {{ raw: string, fields: Record<string, string | string[]> }[]} componentRecords
|
|
18
|
+
* @returns {Map<string, string>}
|
|
19
|
+
*/
|
|
20
|
+
static collectActiveMultipartOwnerParts(records, componentRecords) {
|
|
21
|
+
const partBounds = new Map()
|
|
22
|
+
const ownerBounds = new Map()
|
|
23
|
+
const directOwnerIndexesByRecord = new WeakMap()
|
|
24
|
+
|
|
25
|
+
for (const record of records) {
|
|
26
|
+
const ownerIndex = getField(record.fields, 'OwnerIndex')
|
|
27
|
+
const ownerPartId = getField(record.fields, 'OwnerPartId')
|
|
28
|
+
|
|
29
|
+
if (!ownerIndex || !ownerPartId || ownerPartId === '-1') {
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const points =
|
|
34
|
+
SchematicMultipartOwnerMatcher.#collectSchematicRecordPoints(
|
|
35
|
+
record.fields
|
|
36
|
+
)
|
|
37
|
+
if (!points.length) {
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const key = ownerIndex + '::' + ownerPartId
|
|
42
|
+
const existingBounds = partBounds.get(key) || {
|
|
43
|
+
ownerIndex,
|
|
44
|
+
ownerPartId,
|
|
45
|
+
minX: Number.POSITIVE_INFINITY,
|
|
46
|
+
minY: Number.POSITIVE_INFINITY,
|
|
47
|
+
maxX: Number.NEGATIVE_INFINITY,
|
|
48
|
+
maxY: Number.NEGATIVE_INFINITY,
|
|
49
|
+
leftPinLength: 0,
|
|
50
|
+
rightPinLength: 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
SchematicMultipartOwnerMatcher.#expandBounds(existingBounds, points)
|
|
54
|
+
|
|
55
|
+
existingBounds.leftPinLength = Math.max(
|
|
56
|
+
existingBounds.leftPinLength,
|
|
57
|
+
SchematicMultipartOwnerMatcher.#collectLeftPinLength(
|
|
58
|
+
record.fields
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
existingBounds.rightPinLength = Math.max(
|
|
62
|
+
existingBounds.rightPinLength,
|
|
63
|
+
SchematicMultipartOwnerMatcher.#collectRightPinLength(
|
|
64
|
+
record.fields
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
partBounds.set(key, existingBounds)
|
|
69
|
+
|
|
70
|
+
const existingOwnerBounds = ownerBounds.get(ownerIndex) || {
|
|
71
|
+
ownerIndex,
|
|
72
|
+
minX: Number.POSITIVE_INFINITY,
|
|
73
|
+
minY: Number.POSITIVE_INFINITY,
|
|
74
|
+
maxX: Number.NEGATIVE_INFINITY,
|
|
75
|
+
maxY: Number.NEGATIVE_INFINITY
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
SchematicMultipartOwnerMatcher.#expandBounds(
|
|
79
|
+
existingOwnerBounds,
|
|
80
|
+
points
|
|
81
|
+
)
|
|
82
|
+
ownerBounds.set(ownerIndex, existingOwnerBounds)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
86
|
+
const record = records[index]
|
|
87
|
+
if (getField(record.fields, 'RECORD') !== '1') {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const currentPartId = String(
|
|
92
|
+
parseNumericField(record.fields, 'CurrentPartId') || ''
|
|
93
|
+
)
|
|
94
|
+
const partCount = parseNumericField(record.fields, 'PartCount') || 0
|
|
95
|
+
|
|
96
|
+
if (!currentPartId || partCount <= 1) {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const directOwnerIndex =
|
|
101
|
+
SchematicMultipartOwnerMatcher.#findSerializedOwnerIndex(
|
|
102
|
+
records,
|
|
103
|
+
index
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if (!directOwnerIndex) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
directOwnerIndexesByRecord.set(record, directOwnerIndex)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const activeOwnerParts = new Map()
|
|
114
|
+
|
|
115
|
+
for (const record of componentRecords) {
|
|
116
|
+
const currentPartId = String(
|
|
117
|
+
parseNumericField(record.fields, 'CurrentPartId') || ''
|
|
118
|
+
)
|
|
119
|
+
const partCount = parseNumericField(record.fields, 'PartCount') || 0
|
|
120
|
+
const x = parseNumericField(record.fields, 'Location.X')
|
|
121
|
+
const y = parseNumericField(record.fields, 'Location.Y')
|
|
122
|
+
const isMirrored = parseBoolean(record.fields.IsMirrored)
|
|
123
|
+
|
|
124
|
+
if (!currentPartId || partCount <= 1 || x === null || y === null) {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const directOwnerIndex = directOwnerIndexesByRecord.get(record)
|
|
129
|
+
|
|
130
|
+
if (directOwnerIndex) {
|
|
131
|
+
activeOwnerParts.set(directOwnerIndex, currentPartId)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const bestPartMatch =
|
|
136
|
+
SchematicMultipartOwnerMatcher.#findBestPartBoundsMatch(
|
|
137
|
+
partBounds,
|
|
138
|
+
currentPartId,
|
|
139
|
+
x,
|
|
140
|
+
y,
|
|
141
|
+
isMirrored
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if (bestPartMatch && bestPartMatch.score <= 4) {
|
|
145
|
+
activeOwnerParts.set(
|
|
146
|
+
bestPartMatch.ownerIndex,
|
|
147
|
+
bestPartMatch.ownerPartId
|
|
148
|
+
)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const bestOwnerMatch =
|
|
153
|
+
SchematicMultipartOwnerMatcher.#findBestOwnerBoundsMatch(
|
|
154
|
+
ownerBounds,
|
|
155
|
+
x,
|
|
156
|
+
y
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if (bestOwnerMatch && bestOwnerMatch.score <= 4) {
|
|
160
|
+
activeOwnerParts.set(bestOwnerMatch.ownerIndex, currentPartId)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return activeOwnerParts
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolves the dominant owner index serialized after one component record.
|
|
169
|
+
* This preserves multipart selection when library origins do not align with
|
|
170
|
+
* the current geometric anchor heuristics.
|
|
171
|
+
* @param {{ raw: string, fields: Record<string, string | string[]> }[]} records
|
|
172
|
+
* @param {number} componentIndex
|
|
173
|
+
* @returns {string}
|
|
174
|
+
*/
|
|
175
|
+
static #findSerializedOwnerIndex(records, componentIndex) {
|
|
176
|
+
const ownerCounts = new Map()
|
|
177
|
+
const firstSeenOrder = new Map()
|
|
178
|
+
|
|
179
|
+
for (
|
|
180
|
+
let index = componentIndex + 1;
|
|
181
|
+
index < records.length;
|
|
182
|
+
index += 1
|
|
183
|
+
) {
|
|
184
|
+
const record = records[index]
|
|
185
|
+
if (getField(record.fields, 'RECORD') === '1') {
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
!SchematicMultipartOwnerMatcher.#isSerializedOwnerCandidate(
|
|
191
|
+
record.fields
|
|
192
|
+
)
|
|
193
|
+
) {
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ownerIndex = getField(record.fields, 'OwnerIndex')
|
|
198
|
+
|
|
199
|
+
if (!firstSeenOrder.has(ownerIndex)) {
|
|
200
|
+
firstSeenOrder.set(ownerIndex, firstSeenOrder.size)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
ownerCounts.set(ownerIndex, (ownerCounts.get(ownerIndex) || 0) + 1)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const bestOwner = [...ownerCounts.entries()].sort((left, right) => {
|
|
207
|
+
if (left[1] !== right[1]) {
|
|
208
|
+
return right[1] - left[1]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return firstSeenOrder.get(left[0]) - firstSeenOrder.get(right[0])
|
|
212
|
+
})[0]
|
|
213
|
+
|
|
214
|
+
if (!bestOwner) {
|
|
215
|
+
return ''
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const secondBestCount =
|
|
219
|
+
[...ownerCounts.values()].sort((left, right) => right - left)[1] ||
|
|
220
|
+
0
|
|
221
|
+
const [ownerIndex, bestCount] = bestOwner
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
bestCount < 3 ||
|
|
225
|
+
(secondBestCount > 0 && bestCount < secondBestCount * 3)
|
|
226
|
+
) {
|
|
227
|
+
return ''
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return ownerIndex
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns true when one serialized record contributes to the dominant
|
|
235
|
+
* owner block for a placed component.
|
|
236
|
+
* @param {Record<string, string | string[]>} fields
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
static #isSerializedOwnerCandidate(fields) {
|
|
240
|
+
const ownerIndex = getField(fields, 'OwnerIndex')
|
|
241
|
+
const recordType = getField(fields, 'RECORD')
|
|
242
|
+
|
|
243
|
+
if (!ownerIndex) {
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (['45', '46', '48'].includes(recordType)) {
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return !(
|
|
252
|
+
recordType === '41' && getField(fields, 'Name') === 'PinUniqueId'
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Returns true when one schematic record belongs to the selected visible
|
|
258
|
+
* part for a multipart owner.
|
|
259
|
+
* @param {Record<string, string | string[]>} fields
|
|
260
|
+
* @param {Map<string, string>} activeMultipartOwnerParts
|
|
261
|
+
* @returns {boolean}
|
|
262
|
+
*/
|
|
263
|
+
static isActiveOwnerPartRecord(fields, activeMultipartOwnerParts) {
|
|
264
|
+
const ownerIndex = getField(fields, 'OwnerIndex')
|
|
265
|
+
if (!ownerIndex) {
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const activePartId = activeMultipartOwnerParts.get(ownerIndex)
|
|
270
|
+
if (!activePartId) {
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const ownerPartId = getField(fields, 'OwnerPartId')
|
|
275
|
+
if (!ownerPartId || ownerPartId === '-1') {
|
|
276
|
+
return true
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return ownerPartId === activePartId
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Collects the coordinate points embedded in one schematic record.
|
|
284
|
+
* @param {Record<string, string | string[]>} fields
|
|
285
|
+
* @returns {[number, number][]}
|
|
286
|
+
*/
|
|
287
|
+
static #collectSchematicRecordPoints(fields) {
|
|
288
|
+
const points = []
|
|
289
|
+
const locationX = parseNumericField(fields, 'Location.X')
|
|
290
|
+
const locationY = parseNumericField(fields, 'Location.Y')
|
|
291
|
+
const cornerX = parseNumericField(fields, 'Corner.X')
|
|
292
|
+
const cornerY = parseNumericField(fields, 'Corner.Y')
|
|
293
|
+
const locationCount = parseNumericField(fields, 'LocationCount') || 0
|
|
294
|
+
|
|
295
|
+
if (locationX !== null && locationY !== null) {
|
|
296
|
+
points.push([locationX, locationY])
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (cornerX !== null && cornerY !== null) {
|
|
300
|
+
points.push([cornerX, cornerY])
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
for (let index = 1; index <= locationCount; index += 1) {
|
|
304
|
+
const x = parseNumericField(fields, 'X' + index)
|
|
305
|
+
const y = parseNumericField(fields, 'Y' + index)
|
|
306
|
+
|
|
307
|
+
if (x === null || y === null) {
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
points.push([x, y])
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return points
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Expands one accumulated bounds box to include a list of points.
|
|
319
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
320
|
+
* @param {[number, number][]} points
|
|
321
|
+
* @returns {void}
|
|
322
|
+
*/
|
|
323
|
+
static #expandBounds(bounds, points) {
|
|
324
|
+
for (const [x, y] of points) {
|
|
325
|
+
bounds.minX = Math.min(bounds.minX, x)
|
|
326
|
+
bounds.minY = Math.min(bounds.minY, y)
|
|
327
|
+
bounds.maxX = Math.max(bounds.maxX, x)
|
|
328
|
+
bounds.maxY = Math.max(bounds.maxY, y)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Finds the closest part-specific multipart bounds match for one component
|
|
334
|
+
* placement using the existing per-part anchor heuristics.
|
|
335
|
+
* @param {Map<string, { ownerIndex: string, ownerPartId: string, minX: number, minY: number, maxX: number, maxY: number, leftPinLength: number, rightPinLength: number }>} partBounds
|
|
336
|
+
* @param {string} currentPartId
|
|
337
|
+
* @param {number} x
|
|
338
|
+
* @param {number} y
|
|
339
|
+
* @param {boolean} isMirrored
|
|
340
|
+
* @returns {{ ownerIndex: string, ownerPartId: string, minX: number, minY: number, maxX: number, maxY: number, leftPinLength: number, rightPinLength: number, score: number } | undefined}
|
|
341
|
+
*/
|
|
342
|
+
static #findBestPartBoundsMatch(
|
|
343
|
+
partBounds,
|
|
344
|
+
currentPartId,
|
|
345
|
+
x,
|
|
346
|
+
y,
|
|
347
|
+
isMirrored
|
|
348
|
+
) {
|
|
349
|
+
return [...partBounds.values()]
|
|
350
|
+
.filter((bounds) => bounds.ownerPartId === currentPartId)
|
|
351
|
+
.map((bounds) => ({
|
|
352
|
+
...bounds,
|
|
353
|
+
score: SchematicMultipartOwnerMatcher.#scoreBoundsAnchor(
|
|
354
|
+
bounds,
|
|
355
|
+
x,
|
|
356
|
+
y,
|
|
357
|
+
isMirrored,
|
|
358
|
+
currentPartId
|
|
359
|
+
)
|
|
360
|
+
}))
|
|
361
|
+
.sort((left, right) => left.score - right.score)[0]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Finds the closest owner-level multipart bounds match for one component
|
|
366
|
+
* placement when the part-specific corner anchors do not line up.
|
|
367
|
+
* @param {Map<string, { ownerIndex: string, minX: number, minY: number, maxX: number, maxY: number }>} ownerBounds
|
|
368
|
+
* @param {number} x
|
|
369
|
+
* @param {number} y
|
|
370
|
+
* @returns {{ ownerIndex: string, minX: number, minY: number, maxX: number, maxY: number, score: number, centerScore: number, area: number } | undefined}
|
|
371
|
+
*/
|
|
372
|
+
static #findBestOwnerBoundsMatch(ownerBounds, x, y) {
|
|
373
|
+
return [...ownerBounds.values()]
|
|
374
|
+
.map((bounds) => ({
|
|
375
|
+
...bounds,
|
|
376
|
+
score: SchematicMultipartOwnerMatcher.#scoreOwnerBoundsMatch(
|
|
377
|
+
bounds,
|
|
378
|
+
x,
|
|
379
|
+
y
|
|
380
|
+
),
|
|
381
|
+
centerScore:
|
|
382
|
+
SchematicMultipartOwnerMatcher.#scoreOwnerBoundsCenter(
|
|
383
|
+
bounds,
|
|
384
|
+
x,
|
|
385
|
+
y
|
|
386
|
+
),
|
|
387
|
+
area: (bounds.maxX - bounds.minX) * (bounds.maxY - bounds.minY)
|
|
388
|
+
}))
|
|
389
|
+
.sort((left, right) => {
|
|
390
|
+
if (left.score !== right.score) {
|
|
391
|
+
return left.score - right.score
|
|
392
|
+
}
|
|
393
|
+
if (left.centerScore !== right.centerScore) {
|
|
394
|
+
return left.centerScore - right.centerScore
|
|
395
|
+
}
|
|
396
|
+
return left.area - right.area
|
|
397
|
+
})[0]
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Scores how far one component placement sits outside an owner's overall
|
|
402
|
+
* multipart bounds. Points inside the box score zero.
|
|
403
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
404
|
+
* @param {number} x
|
|
405
|
+
* @param {number} y
|
|
406
|
+
* @returns {number}
|
|
407
|
+
*/
|
|
408
|
+
static #scoreOwnerBoundsMatch(bounds, x, y) {
|
|
409
|
+
const distanceX =
|
|
410
|
+
x < bounds.minX ? bounds.minX - x : Math.max(0, x - bounds.maxX)
|
|
411
|
+
const distanceY =
|
|
412
|
+
y < bounds.minY ? bounds.minY - y : Math.max(0, y - bounds.maxY)
|
|
413
|
+
|
|
414
|
+
return distanceX + distanceY
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Scores how close one component placement is to the center of an owner's
|
|
419
|
+
* overall bounds so overlapping matches prefer the most local owner.
|
|
420
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
421
|
+
* @param {number} x
|
|
422
|
+
* @param {number} y
|
|
423
|
+
* @returns {number}
|
|
424
|
+
*/
|
|
425
|
+
static #scoreOwnerBoundsCenter(bounds, x, y) {
|
|
426
|
+
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
427
|
+
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
428
|
+
|
|
429
|
+
return Math.abs(centerX - x) + Math.abs(centerY - y)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Scores how closely one component placement matches the corners of one
|
|
434
|
+
* multipart part bounds box. Altium mirrored units can anchor on the
|
|
435
|
+
* right-hand side instead of the default top-left corner.
|
|
436
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
437
|
+
* @param {number} x
|
|
438
|
+
* @param {number} y
|
|
439
|
+
* @param {boolean} isMirrored
|
|
440
|
+
* @param {string} currentPartId
|
|
441
|
+
* @returns {number}
|
|
442
|
+
*/
|
|
443
|
+
static #scoreBoundsAnchor(bounds, x, y, isMirrored, currentPartId) {
|
|
444
|
+
const midpointY = (bounds.minY + bounds.maxY) / 2
|
|
445
|
+
const scores = []
|
|
446
|
+
|
|
447
|
+
scores.push(Math.abs(bounds.minX - x) + Math.abs(bounds.minY - y))
|
|
448
|
+
|
|
449
|
+
if (
|
|
450
|
+
SchematicMultipartOwnerMatcher.#isCompactHorizontalMultipart(
|
|
451
|
+
bounds
|
|
452
|
+
) &&
|
|
453
|
+
bounds.leftPinLength > 0
|
|
454
|
+
) {
|
|
455
|
+
scores.push(
|
|
456
|
+
Math.abs(bounds.minX - bounds.leftPinLength - x) +
|
|
457
|
+
Math.abs(midpointY - y)
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (isMirrored) {
|
|
462
|
+
scores.push(
|
|
463
|
+
Math.abs(bounds.maxX - x) + Math.abs(bounds.minY - y),
|
|
464
|
+
Math.abs(bounds.maxX - x) + Math.abs(bounds.maxY - y)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if (bounds.rightPinLength > 0) {
|
|
468
|
+
scores.push(
|
|
469
|
+
Math.abs(bounds.maxX + bounds.rightPinLength - x) +
|
|
470
|
+
Math.abs(midpointY - y)
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return Math.min(...scores)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Collects the left pin length for one raw schematic pin record.
|
|
480
|
+
* @param {Record<string, string | string[]>} fields
|
|
481
|
+
* @returns {number}
|
|
482
|
+
*/
|
|
483
|
+
static #collectLeftPinLength(fields) {
|
|
484
|
+
if (getField(fields, 'RECORD') !== '2') {
|
|
485
|
+
return 0
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const pinLength = parseNumericField(fields, 'PinLength')
|
|
489
|
+
const orientation =
|
|
490
|
+
SchematicMultipartOwnerMatcher.#inferSchematicPinOrientation(
|
|
491
|
+
parseNumericField(fields, 'PinConglomerate')
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if (pinLength === null || pinLength <= 0 || orientation !== 'left') {
|
|
495
|
+
return 0
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return pinLength
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Collects the right pin length for one raw schematic pin record.
|
|
503
|
+
* @param {Record<string, string | string[]>} fields
|
|
504
|
+
* @returns {number}
|
|
505
|
+
*/
|
|
506
|
+
static #collectRightPinLength(fields) {
|
|
507
|
+
if (getField(fields, 'RECORD') !== '2') {
|
|
508
|
+
return 0
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const pinLength = parseNumericField(fields, 'PinLength')
|
|
512
|
+
const orientation =
|
|
513
|
+
SchematicMultipartOwnerMatcher.#inferSchematicPinOrientation(
|
|
514
|
+
parseNumericField(fields, 'PinConglomerate')
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
if (pinLength === null || pinLength <= 0 || orientation !== 'right') {
|
|
518
|
+
return 0
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return pinLength
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Returns true when one owner bounds box looks like a compact horizontal
|
|
526
|
+
* passive multipart unit anchored from its left pin endpoint.
|
|
527
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
528
|
+
* @returns {boolean}
|
|
529
|
+
*/
|
|
530
|
+
static #isCompactHorizontalMultipart(bounds) {
|
|
531
|
+
const width = bounds.maxX - bounds.minX
|
|
532
|
+
const height = bounds.maxY - bounds.minY
|
|
533
|
+
|
|
534
|
+
return width <= 30 && height <= 20 && width > height
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Maps raw pin conglomerates into schematic pin orientations.
|
|
539
|
+
* @param {number | null} conglomerate
|
|
540
|
+
* @returns {'left' | 'right' | 'top' | 'bottom' | null}
|
|
541
|
+
*/
|
|
542
|
+
static #inferSchematicPinOrientation(conglomerate) {
|
|
543
|
+
switch (conglomerate) {
|
|
544
|
+
case 34:
|
|
545
|
+
case 50:
|
|
546
|
+
case 58:
|
|
547
|
+
return 'left'
|
|
548
|
+
case 32:
|
|
549
|
+
case 48:
|
|
550
|
+
case 56:
|
|
551
|
+
return 'right'
|
|
552
|
+
case 35:
|
|
553
|
+
case 51:
|
|
554
|
+
case 59:
|
|
555
|
+
return 'top'
|
|
556
|
+
case 33:
|
|
557
|
+
case 49:
|
|
558
|
+
case 57:
|
|
559
|
+
return 'bottom'
|
|
560
|
+
default:
|
|
561
|
+
return null
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|