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,801 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Applies placement-oriented cleanup passes to normalized schematic text.
|
|
7
|
+
*/
|
|
8
|
+
export class SchematicTextPostProcessor {
|
|
9
|
+
/**
|
|
10
|
+
* Removes free text labels already covered by visible off-sheet ports.
|
|
11
|
+
* @param {{ x: number, y: number, text: string, recordType?: string }[]} texts
|
|
12
|
+
* @param {{ x: number, y: number, width: number, name: string }[]} ports
|
|
13
|
+
* @returns {{ x: number, y: number, text: string, recordType?: string }[]}
|
|
14
|
+
*/
|
|
15
|
+
static dropDuplicatePortLabels(texts, ports) {
|
|
16
|
+
return texts.filter(
|
|
17
|
+
(text) =>
|
|
18
|
+
!ports.some((port) =>
|
|
19
|
+
SchematicTextPostProcessor.#isDuplicatePortLabel(text, port)
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns true when one free wire label duplicates a visible off-sheet port
|
|
26
|
+
* label immediately beside the port body.
|
|
27
|
+
* @param {{ x: number, y: number, text: string, recordType?: string }} text
|
|
28
|
+
* @param {{ x: number, y: number, width: number, name: string }} port
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
static #isDuplicatePortLabel(text, port) {
|
|
32
|
+
if (
|
|
33
|
+
text.recordType !== '25' ||
|
|
34
|
+
port.name !== text.text ||
|
|
35
|
+
Math.abs(port.y - text.y) > 2
|
|
36
|
+
) {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if ((port.direction || 'right') !== 'left') {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const maxGap = Math.max(port.width + 20, 80)
|
|
45
|
+
|
|
46
|
+
return text.x <= port.x && port.x - text.x <= maxGap
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Adds multipart section suffixes like A/B/J to visible designator texts
|
|
51
|
+
* when the active Altium part id is stored separately from the base
|
|
52
|
+
* designator string.
|
|
53
|
+
* @param {{ text: string, name?: string, ownerIndex?: string, recordType?: string }[]} texts
|
|
54
|
+
* @param {Map<string, string>} activeMultipartOwnerParts
|
|
55
|
+
* @returns {{ text: string, name?: string, ownerIndex?: string, recordType?: string }[]}
|
|
56
|
+
*/
|
|
57
|
+
static decorateMultipartDesignators(texts, activeMultipartOwnerParts) {
|
|
58
|
+
const duplicatedActiveBaseDesignators =
|
|
59
|
+
SchematicTextPostProcessor.#collectDuplicatedActiveMultipartDesignators(
|
|
60
|
+
texts,
|
|
61
|
+
activeMultipartOwnerParts
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return texts.map((text) => {
|
|
65
|
+
const ownerIndex = String(text.ownerIndex || '')
|
|
66
|
+
const suffix =
|
|
67
|
+
SchematicTextPostProcessor.#formatMultipartPartSuffix(
|
|
68
|
+
activeMultipartOwnerParts.get(ownerIndex)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (
|
|
72
|
+
!suffix ||
|
|
73
|
+
text.recordType !== '34' ||
|
|
74
|
+
String(text.name || '')
|
|
75
|
+
.trim()
|
|
76
|
+
.toLowerCase() !== 'designator' ||
|
|
77
|
+
!duplicatedActiveBaseDesignators.has(text.text)
|
|
78
|
+
) {
|
|
79
|
+
return text
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!/\d$/i.test(text.text) || text.text.endsWith(suffix)) {
|
|
83
|
+
return text
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
...text,
|
|
88
|
+
text: text.text + suffix
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Collects base multipart designators that belong to more than one active
|
|
95
|
+
* visible owner on the current sheet.
|
|
96
|
+
* @param {{ text: string, name?: string, ownerIndex?: string, recordType?: string }[]} texts
|
|
97
|
+
* @param {Map<string, string>} activeMultipartOwnerParts
|
|
98
|
+
* @returns {Set<string>}
|
|
99
|
+
*/
|
|
100
|
+
static #collectDuplicatedActiveMultipartDesignators(
|
|
101
|
+
texts,
|
|
102
|
+
activeMultipartOwnerParts
|
|
103
|
+
) {
|
|
104
|
+
const counts = new Map()
|
|
105
|
+
|
|
106
|
+
for (const text of texts) {
|
|
107
|
+
const ownerIndex = String(text.ownerIndex || '')
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
!activeMultipartOwnerParts.has(ownerIndex) ||
|
|
111
|
+
text.recordType !== '34' ||
|
|
112
|
+
String(text.name || '')
|
|
113
|
+
.trim()
|
|
114
|
+
.toLowerCase() !== 'designator' ||
|
|
115
|
+
!/\d$/i.test(String(text.text || '').trim())
|
|
116
|
+
) {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const baseDesignator = String(text.text || '').trim()
|
|
121
|
+
counts.set(baseDesignator, (counts.get(baseDesignator) || 0) + 1)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return new Set(
|
|
125
|
+
[...counts.entries()]
|
|
126
|
+
.filter(([, count]) => count > 1)
|
|
127
|
+
.map(([baseDesignator]) => baseDesignator)
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Re-anchors horizontal component texts from their owner primitive bounds
|
|
133
|
+
* so left-side standalone designators can right-align without disturbing
|
|
134
|
+
* stacked owner-side value text.
|
|
135
|
+
* @param {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, rotation?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
|
|
136
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
|
|
137
|
+
* @param {{ x: number, y: number, ownerIndex: string, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
138
|
+
* @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
|
|
139
|
+
* @returns {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, rotation?: number, anchor?: 'start' | 'middle' | 'end' }[]}
|
|
140
|
+
*/
|
|
141
|
+
static anchorComponentTextsFromOwnerBounds(texts, lines, pins, ports = []) {
|
|
142
|
+
const ownerBounds = SchematicTextPostProcessor.#buildOwnerBounds(
|
|
143
|
+
lines,
|
|
144
|
+
pins
|
|
145
|
+
)
|
|
146
|
+
const ownerPinCounts =
|
|
147
|
+
SchematicTextPostProcessor.#buildOwnerPinCounts(pins)
|
|
148
|
+
const ownerPinOrientations =
|
|
149
|
+
SchematicTextPostProcessor.#buildOwnerPinOrientations(pins)
|
|
150
|
+
|
|
151
|
+
return texts.map((text) => {
|
|
152
|
+
if (
|
|
153
|
+
!text ||
|
|
154
|
+
!SchematicTextPostProcessor.#isDesignatorText(text) ||
|
|
155
|
+
text.rotation ||
|
|
156
|
+
!text.ownerIndex
|
|
157
|
+
) {
|
|
158
|
+
return text
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const bounds = ownerBounds.get(text.ownerIndex)
|
|
162
|
+
|
|
163
|
+
if (!bounds) {
|
|
164
|
+
return text
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const paddedText = SchematicTextPostProcessor.#isDesignatorText(
|
|
168
|
+
text
|
|
169
|
+
)
|
|
170
|
+
? SchematicTextPostProcessor.#padDesignatorAboveOwner(
|
|
171
|
+
text,
|
|
172
|
+
bounds
|
|
173
|
+
)
|
|
174
|
+
: text
|
|
175
|
+
const ownerPinCount = ownerPinCounts.get(text.ownerIndex) || 0
|
|
176
|
+
|
|
177
|
+
if (text.y > bounds.maxY) {
|
|
178
|
+
return paddedText
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (text.y < bounds.minY - 1) {
|
|
182
|
+
return paddedText
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (paddedText.x <= bounds.minX + 2) {
|
|
186
|
+
if (
|
|
187
|
+
SchematicTextPostProcessor.#hasVisibleOwnerSideTextStack(
|
|
188
|
+
paddedText,
|
|
189
|
+
texts,
|
|
190
|
+
bounds
|
|
191
|
+
) ||
|
|
192
|
+
SchematicTextPostProcessor.#hasVisibleOppositeSideValuePair(
|
|
193
|
+
paddedText,
|
|
194
|
+
texts,
|
|
195
|
+
bounds,
|
|
196
|
+
ownerPinOrientations.get(text.ownerIndex) || new Set()
|
|
197
|
+
) ||
|
|
198
|
+
SchematicTextPostProcessor.#hasNearbyLeftWireLabel(
|
|
199
|
+
paddedText,
|
|
200
|
+
texts,
|
|
201
|
+
lines,
|
|
202
|
+
pins,
|
|
203
|
+
ports
|
|
204
|
+
) ||
|
|
205
|
+
SchematicTextPostProcessor.#isCompactTwoPinOwner(
|
|
206
|
+
bounds,
|
|
207
|
+
ownerPinCount
|
|
208
|
+
)
|
|
209
|
+
) {
|
|
210
|
+
return paddedText
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
...paddedText,
|
|
215
|
+
anchor: 'end'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (paddedText.x >= bounds.maxX - 2) {
|
|
220
|
+
return {
|
|
221
|
+
...paddedText,
|
|
222
|
+
anchor: 'start'
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return paddedText
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Right-aligns wire labels that precede a same-row component designator so
|
|
232
|
+
* they stay clear of the symbol body.
|
|
233
|
+
* Labels that sit on a wire segment whose left endpoint is an actual pin
|
|
234
|
+
* or off-sheet port keep their original left-to-right flow.
|
|
235
|
+
* @param {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, rotation?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
|
|
236
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
|
|
237
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
238
|
+
* @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
|
|
239
|
+
* @returns {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, rotation?: number, anchor?: 'start' | 'middle' | 'end' }[]}
|
|
240
|
+
*/
|
|
241
|
+
static anchorWireLabelsNearDesignators(texts, lines, pins, ports = []) {
|
|
242
|
+
return texts.map((text) => {
|
|
243
|
+
if (
|
|
244
|
+
!text ||
|
|
245
|
+
text.recordType !== '25' ||
|
|
246
|
+
text.rotation ||
|
|
247
|
+
text.anchor !== 'start'
|
|
248
|
+
) {
|
|
249
|
+
return text
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const hasNearbyRightDesignator = texts.some(
|
|
253
|
+
(candidate) =>
|
|
254
|
+
candidate &&
|
|
255
|
+
candidate.name === 'Designator' &&
|
|
256
|
+
!candidate.rotation &&
|
|
257
|
+
candidate.x > text.x &&
|
|
258
|
+
candidate.x - text.x <= 80 &&
|
|
259
|
+
Math.abs(candidate.y - text.y) <= 2
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if (!hasNearbyRightDesignator) {
|
|
263
|
+
return text
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (
|
|
267
|
+
SchematicTextPostProcessor.#hasPinConnectedAtWireStart(
|
|
268
|
+
text,
|
|
269
|
+
lines,
|
|
270
|
+
pins
|
|
271
|
+
) ||
|
|
272
|
+
SchematicTextPostProcessor.#hasLineConnectedAtWireStart(
|
|
273
|
+
text,
|
|
274
|
+
lines
|
|
275
|
+
) ||
|
|
276
|
+
SchematicTextPostProcessor.#hasPortConnectedAtWireStart(
|
|
277
|
+
text,
|
|
278
|
+
lines,
|
|
279
|
+
ports
|
|
280
|
+
)
|
|
281
|
+
) {
|
|
282
|
+
return text
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
...text,
|
|
287
|
+
anchor: 'end'
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Builds per-owner primitive bounds from drawable lines and pins.
|
|
294
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
|
|
295
|
+
* @param {{ x: number, y: number, ownerIndex: string }[]} pins
|
|
296
|
+
* @returns {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>}
|
|
297
|
+
*/
|
|
298
|
+
static #buildOwnerBounds(lines, pins) {
|
|
299
|
+
const ownerBounds = new Map()
|
|
300
|
+
|
|
301
|
+
for (const line of lines) {
|
|
302
|
+
if (!line.ownerIndex) {
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
SchematicTextPostProcessor.#extendBounds(
|
|
307
|
+
ownerBounds,
|
|
308
|
+
line.ownerIndex,
|
|
309
|
+
[
|
|
310
|
+
{ x: line.x1, y: line.y1 },
|
|
311
|
+
{ x: line.x2, y: line.y2 }
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const pin of pins) {
|
|
317
|
+
if (!pin.ownerIndex) {
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
SchematicTextPostProcessor.#extendBounds(
|
|
322
|
+
ownerBounds,
|
|
323
|
+
pin.ownerIndex,
|
|
324
|
+
[{ x: pin.x, y: pin.y }]
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return ownerBounds
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Counts visible pins per owner so compact passive parts can keep their
|
|
333
|
+
* left-to-right designator flow.
|
|
334
|
+
* @param {{ ownerIndex: string }[]} pins
|
|
335
|
+
* @returns {Map<string, number>}
|
|
336
|
+
*/
|
|
337
|
+
static #buildOwnerPinCounts(pins) {
|
|
338
|
+
const ownerPinCounts = new Map()
|
|
339
|
+
|
|
340
|
+
for (const pin of pins) {
|
|
341
|
+
if (!pin.ownerIndex) {
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
ownerPinCounts.set(
|
|
346
|
+
pin.ownerIndex,
|
|
347
|
+
(ownerPinCounts.get(pin.ownerIndex) || 0) + 1
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return ownerPinCounts
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Collects the visible pin orientations per owner.
|
|
356
|
+
* @param {{ ownerIndex: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
357
|
+
* @returns {Map<string, Set<'left' | 'right' | 'top' | 'bottom'>>}
|
|
358
|
+
*/
|
|
359
|
+
static #buildOwnerPinOrientations(pins) {
|
|
360
|
+
const ownerPinOrientations = new Map()
|
|
361
|
+
|
|
362
|
+
for (const pin of pins) {
|
|
363
|
+
if (!pin.ownerIndex || !pin.orientation) {
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!ownerPinOrientations.has(pin.ownerIndex)) {
|
|
368
|
+
ownerPinOrientations.set(pin.ownerIndex, new Set())
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
ownerPinOrientations.get(pin.ownerIndex).add(pin.orientation)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return ownerPinOrientations
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Returns true when a text is a visible component designator.
|
|
379
|
+
* @param {{ name?: string }} text
|
|
380
|
+
* @returns {boolean}
|
|
381
|
+
*/
|
|
382
|
+
static #isDesignatorText(text) {
|
|
383
|
+
return (
|
|
384
|
+
String(text.name || '')
|
|
385
|
+
.trim()
|
|
386
|
+
.toLowerCase() === 'designator'
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Returns true when a left-side designator shares its owner-side stack with
|
|
392
|
+
* a visible value or comment text at the same owner-side x position.
|
|
393
|
+
* @param {{ x: number, y: number, ownerIndex?: string }} text
|
|
394
|
+
* @param {{ x: number, y: number, name?: string, ownerIndex?: string, rotation?: number }[]} texts
|
|
395
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
396
|
+
* @returns {boolean}
|
|
397
|
+
*/
|
|
398
|
+
static #hasVisibleOwnerSideTextStack(text, texts, bounds) {
|
|
399
|
+
const side = SchematicTextPostProcessor.#resolveOwnerSide(text, bounds)
|
|
400
|
+
|
|
401
|
+
if (!side) {
|
|
402
|
+
return false
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return texts.some((candidate) => {
|
|
406
|
+
const normalizedName = String(candidate?.name || '')
|
|
407
|
+
.trim()
|
|
408
|
+
.toLowerCase()
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
candidate &&
|
|
412
|
+
candidate !== text &&
|
|
413
|
+
candidate.ownerIndex === text.ownerIndex &&
|
|
414
|
+
!candidate.rotation &&
|
|
415
|
+
(normalizedName === 'value' || normalizedName === 'comment') &&
|
|
416
|
+
Math.abs(candidate.x - text.x) <= 2 &&
|
|
417
|
+
SchematicTextPostProcessor.#isTextOnOwnerSide(
|
|
418
|
+
candidate,
|
|
419
|
+
bounds,
|
|
420
|
+
side
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Resolves which horizontal owner side a text sits on.
|
|
428
|
+
* @param {{ x: number }} text
|
|
429
|
+
* @param {{ minX: number, maxX: number }} bounds
|
|
430
|
+
* @returns {'left' | 'right' | null}
|
|
431
|
+
*/
|
|
432
|
+
static #resolveOwnerSide(text, bounds) {
|
|
433
|
+
if (text.x <= bounds.minX + 2) {
|
|
434
|
+
return 'left'
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (text.x >= bounds.maxX - 2) {
|
|
438
|
+
return 'right'
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Returns true when a text sits on the requested horizontal owner side.
|
|
446
|
+
* @param {{ x: number }} text
|
|
447
|
+
* @param {{ minX: number, maxX: number }} bounds
|
|
448
|
+
* @param {'left' | 'right'} side
|
|
449
|
+
* @returns {boolean}
|
|
450
|
+
*/
|
|
451
|
+
static #isTextOnOwnerSide(text, bounds, side) {
|
|
452
|
+
if (side === 'left') {
|
|
453
|
+
return text.x <= bounds.minX + 2
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return text.x >= bounds.maxX - 2
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Returns true when a horizontal owner already exposes a visible
|
|
461
|
+
* value/comment on the far side of the body, so the left designator should
|
|
462
|
+
* keep its original left-to-right source anchor.
|
|
463
|
+
* @param {{ x: number, y: number, ownerIndex?: string }} text
|
|
464
|
+
* @param {{ x: number, y: number, name?: string, ownerIndex?: string, rotation?: number }[]} texts
|
|
465
|
+
* @param {{ minX: number, maxX: number }} bounds
|
|
466
|
+
* @param {Set<'left' | 'right' | 'top' | 'bottom'>} ownerOrientations
|
|
467
|
+
* @returns {boolean}
|
|
468
|
+
*/
|
|
469
|
+
static #hasVisibleOppositeSideValuePair(
|
|
470
|
+
text,
|
|
471
|
+
texts,
|
|
472
|
+
bounds,
|
|
473
|
+
ownerOrientations
|
|
474
|
+
) {
|
|
475
|
+
if (
|
|
476
|
+
!ownerOrientations.has('left') ||
|
|
477
|
+
!ownerOrientations.has('right') ||
|
|
478
|
+
ownerOrientations.has('top') ||
|
|
479
|
+
ownerOrientations.has('bottom')
|
|
480
|
+
) {
|
|
481
|
+
return false
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return texts.some((candidate) => {
|
|
485
|
+
const normalizedName = String(candidate?.name || '')
|
|
486
|
+
.trim()
|
|
487
|
+
.toLowerCase()
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
candidate &&
|
|
491
|
+
candidate !== text &&
|
|
492
|
+
candidate.ownerIndex === text.ownerIndex &&
|
|
493
|
+
!candidate.rotation &&
|
|
494
|
+
(normalizedName === 'value' || normalizedName === 'comment') &&
|
|
495
|
+
candidate.x >= bounds.maxX - 2 &&
|
|
496
|
+
candidate.x > text.x &&
|
|
497
|
+
Math.abs(candidate.y - text.y) <= 2
|
|
498
|
+
)
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Adds a small gap between a top-side designator and the owner outline.
|
|
504
|
+
* @param {{ x: number, y: number }} text
|
|
505
|
+
* @param {{ maxY: number }} bounds
|
|
506
|
+
* @returns {{ x: number, y: number }}
|
|
507
|
+
*/
|
|
508
|
+
static #padDesignatorAboveOwner(text, bounds) {
|
|
509
|
+
if (text.y <= bounds.maxY) {
|
|
510
|
+
return text
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
...text,
|
|
515
|
+
y: bounds.maxY + 4
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Returns true for compact two-pin symbols whose left-side designators
|
|
521
|
+
* should keep reading left-to-right instead of flipping toward the body.
|
|
522
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
523
|
+
* @param {number} ownerPinCount
|
|
524
|
+
* @returns {boolean}
|
|
525
|
+
*/
|
|
526
|
+
static #isCompactTwoPinOwner(bounds, ownerPinCount) {
|
|
527
|
+
return (
|
|
528
|
+
ownerPinCount === 2 &&
|
|
529
|
+
bounds.maxX - bounds.minX <= 12 &&
|
|
530
|
+
bounds.maxY - bounds.minY <= 20
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Returns true when a component text sits immediately to the right of a
|
|
536
|
+
* visible same-row wire label and should preserve the left-to-right flow.
|
|
537
|
+
* @param {{ x: number, y: number, recordType?: string, rotation?: number }} text
|
|
538
|
+
* @param {{ x: number, y: number, recordType?: string, rotation?: number }}[] texts
|
|
539
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
|
|
540
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
541
|
+
* @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
|
|
542
|
+
* @returns {boolean}
|
|
543
|
+
*/
|
|
544
|
+
static #hasNearbyLeftWireLabel(text, texts, lines, pins, ports) {
|
|
545
|
+
return texts.some(
|
|
546
|
+
(candidate) =>
|
|
547
|
+
candidate &&
|
|
548
|
+
candidate !== text &&
|
|
549
|
+
candidate.recordType === '25' &&
|
|
550
|
+
!candidate.rotation &&
|
|
551
|
+
candidate.x < text.x &&
|
|
552
|
+
text.x - candidate.x <= 80 &&
|
|
553
|
+
Math.abs(candidate.y - text.y) <= 2 &&
|
|
554
|
+
(SchematicTextPostProcessor.#hasPinConnectedAtWireStart(
|
|
555
|
+
candidate,
|
|
556
|
+
lines,
|
|
557
|
+
pins
|
|
558
|
+
) ||
|
|
559
|
+
SchematicTextPostProcessor.#hasLineConnectedAtWireStart(
|
|
560
|
+
candidate,
|
|
561
|
+
lines
|
|
562
|
+
) ||
|
|
563
|
+
SchematicTextPostProcessor.#hasPortConnectedAtWireStart(
|
|
564
|
+
candidate,
|
|
565
|
+
lines,
|
|
566
|
+
ports
|
|
567
|
+
))
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Returns true when the left endpoint of the label's wire segment is
|
|
573
|
+
* already connected into another wire segment, such as a bus breakout.
|
|
574
|
+
* Those labels should keep reading left-to-right from the junction.
|
|
575
|
+
* @param {{ x: number, y: number }} text
|
|
576
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
|
|
577
|
+
* @returns {boolean}
|
|
578
|
+
*/
|
|
579
|
+
static #hasLineConnectedAtWireStart(text, lines) {
|
|
580
|
+
const containingSegment =
|
|
581
|
+
SchematicTextPostProcessor.#findContainingHorizontalWireSegment(
|
|
582
|
+
text,
|
|
583
|
+
lines
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
if (!containingSegment) {
|
|
587
|
+
return false
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const leftPoint = {
|
|
591
|
+
x: Math.min(containingSegment.x1, containingSegment.x2),
|
|
592
|
+
y: text.y
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return lines.some(
|
|
596
|
+
(line) =>
|
|
597
|
+
line !== containingSegment &&
|
|
598
|
+
SchematicTextPostProcessor.#pointTouchesLine(leftPoint, line)
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Converts one numeric multipart section id into an alphabetic suffix.
|
|
604
|
+
* @param {string | undefined} partId
|
|
605
|
+
* @returns {string}
|
|
606
|
+
*/
|
|
607
|
+
static #formatMultipartPartSuffix(partId) {
|
|
608
|
+
const numericPartId = Number.parseInt(String(partId || ''), 10)
|
|
609
|
+
if (!Number.isInteger(numericPartId) || numericPartId <= 0) {
|
|
610
|
+
return ''
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let suffix = ''
|
|
614
|
+
let remaining = numericPartId
|
|
615
|
+
|
|
616
|
+
while (remaining > 0) {
|
|
617
|
+
remaining -= 1
|
|
618
|
+
suffix = String.fromCharCode(65 + (remaining % 26)) + suffix
|
|
619
|
+
remaining = Math.floor(remaining / 26)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return suffix
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Returns true when the horizontal wire segment under the label starts at a
|
|
627
|
+
* pin endpoint, which means the label should continue reading rightward.
|
|
628
|
+
* @param {{ x: number, y: number }} text
|
|
629
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
|
|
630
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
631
|
+
* @returns {boolean}
|
|
632
|
+
*/
|
|
633
|
+
static #hasPinConnectedAtWireStart(text, lines, pins) {
|
|
634
|
+
const containingSegment =
|
|
635
|
+
SchematicTextPostProcessor.#findContainingHorizontalWireSegment(
|
|
636
|
+
text,
|
|
637
|
+
lines
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if (!containingSegment) {
|
|
641
|
+
return false
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const leftX = Math.min(containingSegment.x1, containingSegment.x2)
|
|
645
|
+
|
|
646
|
+
return pins.some((pin) => {
|
|
647
|
+
const endpoint =
|
|
648
|
+
SchematicTextPostProcessor.#projectPinOuterEndpoint(pin)
|
|
649
|
+
|
|
650
|
+
return (
|
|
651
|
+
endpoint &&
|
|
652
|
+
Math.abs(endpoint.x - leftX) <= 2 &&
|
|
653
|
+
Math.abs(endpoint.y - text.y) <= 2
|
|
654
|
+
)
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Returns true when the horizontal wire segment under the label starts at an
|
|
660
|
+
* off-sheet port connection, which means the label should keep reading
|
|
661
|
+
* rightward from that port.
|
|
662
|
+
* @param {{ x: number, y: number }} text
|
|
663
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
|
|
664
|
+
* @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
|
|
665
|
+
* @returns {boolean}
|
|
666
|
+
*/
|
|
667
|
+
static #hasPortConnectedAtWireStart(text, lines, ports) {
|
|
668
|
+
const containingSegment =
|
|
669
|
+
SchematicTextPostProcessor.#findContainingHorizontalWireSegment(
|
|
670
|
+
text,
|
|
671
|
+
lines
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
if (!containingSegment) {
|
|
675
|
+
return false
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const leftX = Math.min(containingSegment.x1, containingSegment.x2)
|
|
679
|
+
|
|
680
|
+
return ports.some(
|
|
681
|
+
(port) =>
|
|
682
|
+
Math.abs(port.y - text.y) <= 2 &&
|
|
683
|
+
(Math.abs(port.x - leftX) <= 2 ||
|
|
684
|
+
Math.abs(port.x + port.width - leftX) <= 2)
|
|
685
|
+
)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Finds the horizontal wire segment that carries a text label.
|
|
690
|
+
* @param {{ x: number, y: number }} text
|
|
691
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
|
|
692
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string } | null}
|
|
693
|
+
*/
|
|
694
|
+
static #findContainingHorizontalWireSegment(text, lines) {
|
|
695
|
+
const candidates = lines.filter(
|
|
696
|
+
(line) =>
|
|
697
|
+
Math.abs(line.y1 - text.y) <= 2 &&
|
|
698
|
+
Math.abs(line.y2 - text.y) <= 2 &&
|
|
699
|
+
Math.min(line.x1, line.x2) - 2 <= text.x &&
|
|
700
|
+
text.x <= Math.max(line.x1, line.x2) + 2
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
if (!candidates.length) {
|
|
704
|
+
return null
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return candidates.sort(
|
|
708
|
+
(left, right) =>
|
|
709
|
+
Math.abs(Math.min(left.x1, left.x2) - text.x) -
|
|
710
|
+
Math.abs(Math.min(right.x1, right.x2) - text.x)
|
|
711
|
+
)[0]
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Returns true when a point lands on a line segment endpoint or on an
|
|
716
|
+
* axis-aligned segment interior.
|
|
717
|
+
* @param {{ x: number, y: number }} point
|
|
718
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} line
|
|
719
|
+
* @returns {boolean}
|
|
720
|
+
*/
|
|
721
|
+
static #pointTouchesLine(point, line) {
|
|
722
|
+
const touchesStart =
|
|
723
|
+
Math.abs(line.x1 - point.x) <= 2 && Math.abs(line.y1 - point.y) <= 2
|
|
724
|
+
const touchesEnd =
|
|
725
|
+
Math.abs(line.x2 - point.x) <= 2 && Math.abs(line.y2 - point.y) <= 2
|
|
726
|
+
|
|
727
|
+
if (touchesStart || touchesEnd) {
|
|
728
|
+
return true
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const minX = Math.min(line.x1, line.x2) - 2
|
|
732
|
+
const maxX = Math.max(line.x1, line.x2) + 2
|
|
733
|
+
const minY = Math.min(line.y1, line.y2) - 2
|
|
734
|
+
const maxY = Math.max(line.y1, line.y2) + 2
|
|
735
|
+
|
|
736
|
+
if (
|
|
737
|
+
Math.abs(line.x1 - line.x2) <= 2 &&
|
|
738
|
+
Math.abs(point.x - line.x1) <= 2 &&
|
|
739
|
+
point.y >= minY &&
|
|
740
|
+
point.y <= maxY
|
|
741
|
+
) {
|
|
742
|
+
return true
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (
|
|
746
|
+
Math.abs(line.y1 - line.y2) <= 2 &&
|
|
747
|
+
Math.abs(point.y - line.y1) <= 2 &&
|
|
748
|
+
point.x >= minX &&
|
|
749
|
+
point.x <= maxX
|
|
750
|
+
) {
|
|
751
|
+
return true
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return false
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Expands one owner-bound entry with a set of points.
|
|
759
|
+
* @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerBounds
|
|
760
|
+
* @param {string} ownerIndex
|
|
761
|
+
* @param {{ x: number, y: number }[]} points
|
|
762
|
+
* @returns {void}
|
|
763
|
+
*/
|
|
764
|
+
static #extendBounds(ownerBounds, ownerIndex, points) {
|
|
765
|
+
const current = ownerBounds.get(ownerIndex) || {
|
|
766
|
+
minX: Number.POSITIVE_INFINITY,
|
|
767
|
+
minY: Number.POSITIVE_INFINITY,
|
|
768
|
+
maxX: Number.NEGATIVE_INFINITY,
|
|
769
|
+
maxY: Number.NEGATIVE_INFINITY
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
for (const point of points) {
|
|
773
|
+
current.minX = Math.min(current.minX, point.x)
|
|
774
|
+
current.minY = Math.min(current.minY, point.y)
|
|
775
|
+
current.maxX = Math.max(current.maxX, point.x)
|
|
776
|
+
current.maxY = Math.max(current.maxY, point.y)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
ownerBounds.set(ownerIndex, current)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Projects one pin into its wire-connected outer endpoint.
|
|
784
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
|
|
785
|
+
* @returns {{ x: number, y: number } | null}
|
|
786
|
+
*/
|
|
787
|
+
static #projectPinOuterEndpoint(pin) {
|
|
788
|
+
switch (pin.orientation) {
|
|
789
|
+
case 'left':
|
|
790
|
+
return { x: pin.x - pin.length, y: pin.y }
|
|
791
|
+
case 'right':
|
|
792
|
+
return { x: pin.x + pin.length, y: pin.y }
|
|
793
|
+
case 'top':
|
|
794
|
+
return { x: pin.x, y: pin.y + pin.length }
|
|
795
|
+
case 'bottom':
|
|
796
|
+
return { x: pin.x, y: pin.y - pin.length }
|
|
797
|
+
default:
|
|
798
|
+
return null
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|