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,592 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes tiny free-standing dashed note callouts recovered from Altium.
|
|
7
|
+
*/
|
|
8
|
+
export class SchematicStandaloneCalloutNormalizer {
|
|
9
|
+
/**
|
|
10
|
+
* Expands and repositions tiny standalone dashed callouts so the frame
|
|
11
|
+
* encloses the nearby circuit and the title sits in the top band.
|
|
12
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[]} lines
|
|
13
|
+
* @param {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
|
|
14
|
+
* @returns {{ lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[], texts: { x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[] }}
|
|
15
|
+
*/
|
|
16
|
+
static normalize(lines, texts) {
|
|
17
|
+
const dashedFrames =
|
|
18
|
+
SchematicStandaloneCalloutNormalizer.#collectDashedFrameGroups(
|
|
19
|
+
lines
|
|
20
|
+
)
|
|
21
|
+
const consumedLineIndexes = new Set()
|
|
22
|
+
const replacementLines = []
|
|
23
|
+
const replacementTexts = new Map()
|
|
24
|
+
|
|
25
|
+
for (const frame of dashedFrames) {
|
|
26
|
+
const normalizedCallout =
|
|
27
|
+
SchematicStandaloneCalloutNormalizer.#normalizeStandaloneDashedCallout(
|
|
28
|
+
frame,
|
|
29
|
+
lines,
|
|
30
|
+
texts
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if (!normalizedCallout) {
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
frame.indexes.forEach((index) => consumedLineIndexes.add(index))
|
|
38
|
+
replacementLines.push(...normalizedCallout.lines)
|
|
39
|
+
replacementTexts.set(
|
|
40
|
+
normalizedCallout.noteIndex,
|
|
41
|
+
normalizedCallout.noteText
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
lines:
|
|
47
|
+
replacementLines.length > 0
|
|
48
|
+
? lines
|
|
49
|
+
.filter((_, index) => !consumedLineIndexes.has(index))
|
|
50
|
+
.concat(replacementLines)
|
|
51
|
+
: lines,
|
|
52
|
+
texts:
|
|
53
|
+
replacementTexts.size > 0
|
|
54
|
+
? texts.map(
|
|
55
|
+
(text, index) => replacementTexts.get(index) || text
|
|
56
|
+
)
|
|
57
|
+
: texts
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Groups dashed line segments into rectangular frame bounds.
|
|
63
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines
|
|
64
|
+
* @returns {{ bounds: { minX: number, minY: number, maxX: number, maxY: number }, lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[], indexes: number[] }[]}
|
|
65
|
+
*/
|
|
66
|
+
static #collectDashedFrameGroups(lines) {
|
|
67
|
+
const dashedLines = lines.filter(
|
|
68
|
+
(line) => Number(line.lineStyle || 0) === 1
|
|
69
|
+
)
|
|
70
|
+
const dashedIndexes = lines
|
|
71
|
+
.map((line, index) => ({ line, index }))
|
|
72
|
+
.filter(({ line }) => Number(line.lineStyle || 0) === 1)
|
|
73
|
+
const groups = []
|
|
74
|
+
const visited = new Set()
|
|
75
|
+
|
|
76
|
+
for (let index = 0; index < dashedLines.length; index += 1) {
|
|
77
|
+
if (visited.has(index)) {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const queue = [index]
|
|
82
|
+
const group = []
|
|
83
|
+
visited.add(index)
|
|
84
|
+
|
|
85
|
+
while (queue.length > 0) {
|
|
86
|
+
const currentIndex = queue.shift()
|
|
87
|
+
const currentLine = dashedLines[currentIndex]
|
|
88
|
+
group.push(currentLine)
|
|
89
|
+
|
|
90
|
+
for (
|
|
91
|
+
let candidateIndex = 0;
|
|
92
|
+
candidateIndex < dashedLines.length;
|
|
93
|
+
candidateIndex += 1
|
|
94
|
+
) {
|
|
95
|
+
if (
|
|
96
|
+
visited.has(candidateIndex) ||
|
|
97
|
+
!SchematicStandaloneCalloutNormalizer.#linesTouch(
|
|
98
|
+
currentLine,
|
|
99
|
+
dashedLines[candidateIndex]
|
|
100
|
+
)
|
|
101
|
+
) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
visited.add(candidateIndex)
|
|
106
|
+
queue.push(candidateIndex)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const minX = Math.min(
|
|
111
|
+
...group.flatMap((line) => [line.x1, line.x2])
|
|
112
|
+
)
|
|
113
|
+
const maxX = Math.max(
|
|
114
|
+
...group.flatMap((line) => [line.x1, line.x2])
|
|
115
|
+
)
|
|
116
|
+
const minY = Math.min(
|
|
117
|
+
...group.flatMap((line) => [line.y1, line.y2])
|
|
118
|
+
)
|
|
119
|
+
const maxY = Math.max(
|
|
120
|
+
...group.flatMap((line) => [line.y1, line.y2])
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if (group.length >= 4 && maxX > minX && maxY > minY) {
|
|
124
|
+
groups.push({
|
|
125
|
+
bounds: { minX, minY, maxX, maxY },
|
|
126
|
+
lines: group,
|
|
127
|
+
indexes: group.map(
|
|
128
|
+
(line) =>
|
|
129
|
+
dashedIndexes.find(
|
|
130
|
+
({ line: candidate }) => candidate === line
|
|
131
|
+
)?.index
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return groups
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Returns a normalized callout replacement for one tiny free-standing
|
|
142
|
+
* dashed frame, or null when the frame is not the bootstrap-note pattern.
|
|
143
|
+
* @param {{ bounds: { minX: number, minY: number, maxX: number, maxY: number }, lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[], indexes: number[] }} frame
|
|
144
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[]} lines
|
|
145
|
+
* @param {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
|
|
146
|
+
* @returns {{ lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, ownerIndex?: string, isBus?: boolean }[], noteIndex: number, noteText: { x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' } } | null}
|
|
147
|
+
*/
|
|
148
|
+
static #normalizeStandaloneDashedCallout(frame, lines, texts) {
|
|
149
|
+
const frameWidth = frame.bounds.maxX - frame.bounds.minX
|
|
150
|
+
const frameHeight = frame.bounds.maxY - frame.bounds.minY
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
frameWidth > 120 ||
|
|
154
|
+
frameHeight > 60 ||
|
|
155
|
+
frame.lines.some((line) => line.ownerIndex)
|
|
156
|
+
) {
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const noteIndex = texts.findIndex(
|
|
161
|
+
(text) =>
|
|
162
|
+
text &&
|
|
163
|
+
text.recordType === '4' &&
|
|
164
|
+
!text.ownerIndex &&
|
|
165
|
+
Number(text.fontSize || 0) <= 10 &&
|
|
166
|
+
text.x >= frame.bounds.minX - 2 &&
|
|
167
|
+
text.x <= frame.bounds.maxX + 2 &&
|
|
168
|
+
text.y >= frame.bounds.minY - 2 &&
|
|
169
|
+
text.y <= frame.bounds.maxY + 2
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if (noteIndex < 0) {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const noteText = texts[noteIndex]
|
|
177
|
+
const relatedOwnerIndexes =
|
|
178
|
+
SchematicStandaloneCalloutNormalizer.#collectNearbyCalloutOwnerIndexes(
|
|
179
|
+
noteText,
|
|
180
|
+
frame.bounds,
|
|
181
|
+
texts
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if (!relatedOwnerIndexes.size) {
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const contentBounds =
|
|
189
|
+
SchematicStandaloneCalloutNormalizer.#collectStandaloneCalloutContentBounds(
|
|
190
|
+
noteText,
|
|
191
|
+
frame.bounds,
|
|
192
|
+
relatedOwnerIndexes,
|
|
193
|
+
lines,
|
|
194
|
+
texts
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if (!contentBounds) {
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const titleBounds =
|
|
202
|
+
SchematicStandaloneCalloutNormalizer.#estimateTextBounds(noteText)
|
|
203
|
+
const horizontalCenter = (contentBounds.minX + contentBounds.maxX) / 2
|
|
204
|
+
const calloutWidth = Math.max(
|
|
205
|
+
frameWidth + 20,
|
|
206
|
+
contentBounds.maxX - contentBounds.minX + 40,
|
|
207
|
+
titleBounds.maxX - titleBounds.minX + 16
|
|
208
|
+
)
|
|
209
|
+
const minX = Math.round(horizontalCenter - calloutWidth / 2)
|
|
210
|
+
const maxX = Math.round(horizontalCenter + calloutWidth / 2)
|
|
211
|
+
const maxY = Math.round(
|
|
212
|
+
Math.max(
|
|
213
|
+
contentBounds.maxY + 18,
|
|
214
|
+
noteText.y + Number(noteText.fontSize || 8) * 2 + 14
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
const normalizedNoteText = {
|
|
218
|
+
...noteText,
|
|
219
|
+
x: Math.round((minX + maxX) / 2),
|
|
220
|
+
y: Math.round(maxY - (Number(noteText.fontSize || 8) + 6)),
|
|
221
|
+
anchor: 'middle'
|
|
222
|
+
}
|
|
223
|
+
const minY = Math.round(contentBounds.minY - 10)
|
|
224
|
+
const prototype = frame.lines[0]
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
lines: [
|
|
228
|
+
{
|
|
229
|
+
...prototype,
|
|
230
|
+
x1: minX,
|
|
231
|
+
y1: maxY,
|
|
232
|
+
x2: maxX,
|
|
233
|
+
y2: maxY
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
...prototype,
|
|
237
|
+
x1: maxX,
|
|
238
|
+
y1: maxY,
|
|
239
|
+
x2: maxX,
|
|
240
|
+
y2: minY
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
...prototype,
|
|
244
|
+
x1: maxX,
|
|
245
|
+
y1: minY,
|
|
246
|
+
x2: minX,
|
|
247
|
+
y2: minY
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
...prototype,
|
|
251
|
+
x1: minX,
|
|
252
|
+
y1: minY,
|
|
253
|
+
x2: minX,
|
|
254
|
+
y2: maxY
|
|
255
|
+
}
|
|
256
|
+
],
|
|
257
|
+
noteIndex,
|
|
258
|
+
noteText: normalizedNoteText
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Collects owner indexes for designator/value texts immediately below a
|
|
264
|
+
* tiny standalone dashed note.
|
|
265
|
+
* @param {{ x: number, y: number }} noteText
|
|
266
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} frameBounds
|
|
267
|
+
* @param {{ x: number, y: number, name?: string, ownerIndex?: string }[]} texts
|
|
268
|
+
* @returns {Set<string>}
|
|
269
|
+
*/
|
|
270
|
+
static #collectNearbyCalloutOwnerIndexes(noteText, frameBounds, texts) {
|
|
271
|
+
const relatedTexts = texts.filter((text) => {
|
|
272
|
+
const normalizedName = String(text?.name || '')
|
|
273
|
+
.trim()
|
|
274
|
+
.toLowerCase()
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
text &&
|
|
278
|
+
text.ownerIndex &&
|
|
279
|
+
(normalizedName === 'designator' ||
|
|
280
|
+
normalizedName === 'value') &&
|
|
281
|
+
text.x >= frameBounds.minX - 20 &&
|
|
282
|
+
text.x <= noteText.x + 40 &&
|
|
283
|
+
text.y >= frameBounds.minY - 20 &&
|
|
284
|
+
text.y <= frameBounds.maxY + 20
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
return new Set(relatedTexts.map((text) => text.ownerIndex))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Collects the visible content bounds a standalone dashed callout should
|
|
293
|
+
* enclose.
|
|
294
|
+
* @param {{ x: number, y: number }} noteText
|
|
295
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} frameBounds
|
|
296
|
+
* @param {Set<string>} relatedOwnerIndexes
|
|
297
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number, ownerIndex?: string }[]} lines
|
|
298
|
+
* @param {{ x: number, y: number, text: string, ownerIndex?: string, recordType?: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
|
|
299
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
300
|
+
*/
|
|
301
|
+
static #collectStandaloneCalloutContentBounds(
|
|
302
|
+
noteText,
|
|
303
|
+
frameBounds,
|
|
304
|
+
relatedOwnerIndexes,
|
|
305
|
+
lines,
|
|
306
|
+
texts
|
|
307
|
+
) {
|
|
308
|
+
const localWindow = {
|
|
309
|
+
minX: frameBounds.minX - 20,
|
|
310
|
+
minY: frameBounds.minY - 6,
|
|
311
|
+
maxX: frameBounds.maxX + 20,
|
|
312
|
+
maxY: frameBounds.maxY + 10
|
|
313
|
+
}
|
|
314
|
+
const contentLines = lines.filter((line) => {
|
|
315
|
+
if (Number(line.lineStyle || 0) === 1) {
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const lineBounds = {
|
|
320
|
+
minX: Math.min(line.x1, line.x2),
|
|
321
|
+
minY: Math.min(line.y1, line.y2),
|
|
322
|
+
maxX: Math.max(line.x1, line.x2),
|
|
323
|
+
maxY: Math.max(line.y1, line.y2)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (
|
|
327
|
+
!SchematicStandaloneCalloutNormalizer.#boundsOverlap(
|
|
328
|
+
lineBounds,
|
|
329
|
+
localWindow
|
|
330
|
+
)
|
|
331
|
+
) {
|
|
332
|
+
return false
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (relatedOwnerIndexes.has(String(line.ownerIndex || ''))) {
|
|
336
|
+
return true
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
!line.ownerIndex &&
|
|
341
|
+
SchematicStandaloneCalloutNormalizer.#lineEndpointsStayWithinBounds(
|
|
342
|
+
line,
|
|
343
|
+
localWindow
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
})
|
|
347
|
+
const contentTexts = texts.filter((text) => {
|
|
348
|
+
if (!text || text.recordType === '4') {
|
|
349
|
+
return false
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (
|
|
353
|
+
text.ownerIndex &&
|
|
354
|
+
!relatedOwnerIndexes.has(String(text.ownerIndex || ''))
|
|
355
|
+
) {
|
|
356
|
+
return false
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const normalizedName = String(text.name || '')
|
|
360
|
+
.trim()
|
|
361
|
+
.toLowerCase()
|
|
362
|
+
if (
|
|
363
|
+
text.ownerIndex &&
|
|
364
|
+
(normalizedName === 'value' || normalizedName === 'comment') &&
|
|
365
|
+
Number(text.y) < frameBounds.minY
|
|
366
|
+
) {
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return SchematicStandaloneCalloutNormalizer.#boundsOverlap(
|
|
371
|
+
SchematicStandaloneCalloutNormalizer.#estimateTextBounds(text),
|
|
372
|
+
localWindow
|
|
373
|
+
)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
return SchematicStandaloneCalloutNormalizer.#collectPrimitiveBounds(
|
|
377
|
+
contentLines,
|
|
378
|
+
contentTexts
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Collects one union bounds object from line and text primitives.
|
|
384
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
|
|
385
|
+
* @param {{ x: number, y: number, text: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
|
|
386
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
387
|
+
*/
|
|
388
|
+
static #collectPrimitiveBounds(lines, texts) {
|
|
389
|
+
if (!lines.length && !texts.length) {
|
|
390
|
+
return null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const bounds = {
|
|
394
|
+
minX: Number.POSITIVE_INFINITY,
|
|
395
|
+
minY: Number.POSITIVE_INFINITY,
|
|
396
|
+
maxX: Number.NEGATIVE_INFINITY,
|
|
397
|
+
maxY: Number.NEGATIVE_INFINITY
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (const line of lines) {
|
|
401
|
+
bounds.minX = Math.min(bounds.minX, line.x1, line.x2)
|
|
402
|
+
bounds.minY = Math.min(bounds.minY, line.y1, line.y2)
|
|
403
|
+
bounds.maxX = Math.max(bounds.maxX, line.x1, line.x2)
|
|
404
|
+
bounds.maxY = Math.max(bounds.maxY, line.y1, line.y2)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (const text of texts) {
|
|
408
|
+
const textBounds =
|
|
409
|
+
SchematicStandaloneCalloutNormalizer.#estimateTextBounds(text)
|
|
410
|
+
bounds.minX = Math.min(bounds.minX, textBounds.minX)
|
|
411
|
+
bounds.minY = Math.min(bounds.minY, textBounds.minY)
|
|
412
|
+
bounds.maxX = Math.max(bounds.maxX, textBounds.maxX)
|
|
413
|
+
bounds.maxY = Math.max(bounds.maxY, textBounds.maxY)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return bounds
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Returns true when both endpoints stay inside the provided local window.
|
|
421
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} line
|
|
422
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
423
|
+
* @returns {boolean}
|
|
424
|
+
*/
|
|
425
|
+
static #lineEndpointsStayWithinBounds(line, bounds) {
|
|
426
|
+
return (
|
|
427
|
+
(SchematicStandaloneCalloutNormalizer.#boundsContainPoint(bounds, {
|
|
428
|
+
x: line.x1,
|
|
429
|
+
y: line.y1
|
|
430
|
+
}) ||
|
|
431
|
+
false) &&
|
|
432
|
+
SchematicStandaloneCalloutNormalizer.#boundsContainPoint(bounds, {
|
|
433
|
+
x: line.x2,
|
|
434
|
+
y: line.y2
|
|
435
|
+
})
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Returns true when one point lies inside axis-aligned bounds.
|
|
441
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
442
|
+
* @param {{ x: number, y: number }} point
|
|
443
|
+
* @returns {boolean}
|
|
444
|
+
*/
|
|
445
|
+
static #boundsContainPoint(bounds, point) {
|
|
446
|
+
return (
|
|
447
|
+
point.x >= bounds.minX &&
|
|
448
|
+
point.x <= bounds.maxX &&
|
|
449
|
+
point.y >= bounds.minY &&
|
|
450
|
+
point.y <= bounds.maxY
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Approximates schematic text bounds using the serif width factors already
|
|
456
|
+
* used by the SVG renderers.
|
|
457
|
+
* @param {{ x: number, y: number, text: string, fontSize?: number, anchor?: 'start' | 'middle' | 'end' }} text
|
|
458
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
|
|
459
|
+
*/
|
|
460
|
+
static #estimateTextBounds(text) {
|
|
461
|
+
const fontSize = Number(text.fontSize || 10)
|
|
462
|
+
const width = SchematicStandaloneCalloutNormalizer.#estimateTextWidth(
|
|
463
|
+
text.text,
|
|
464
|
+
fontSize
|
|
465
|
+
)
|
|
466
|
+
let minX = Number(text.x)
|
|
467
|
+
let maxX = Number(text.x)
|
|
468
|
+
|
|
469
|
+
if (text.anchor === 'middle') {
|
|
470
|
+
minX -= width / 2
|
|
471
|
+
maxX += width / 2
|
|
472
|
+
} else if (text.anchor === 'end') {
|
|
473
|
+
minX -= width
|
|
474
|
+
} else {
|
|
475
|
+
maxX += width
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
minX,
|
|
480
|
+
minY: Number(text.y) - fontSize * 0.4,
|
|
481
|
+
maxX,
|
|
482
|
+
maxY: Number(text.y) + fontSize * 0.8
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Estimates serif text width for one recovered schematic label.
|
|
488
|
+
* @param {string} text
|
|
489
|
+
* @param {number} fontSize
|
|
490
|
+
* @returns {number}
|
|
491
|
+
*/
|
|
492
|
+
static #estimateTextWidth(text, fontSize) {
|
|
493
|
+
let width = 0
|
|
494
|
+
|
|
495
|
+
for (const character of String(text || '')) {
|
|
496
|
+
width +=
|
|
497
|
+
SchematicStandaloneCalloutNormalizer.#measureCharacterWidth(
|
|
498
|
+
character
|
|
499
|
+
) * fontSize
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return width
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Returns a rough Times New Roman width factor for one character.
|
|
507
|
+
* @param {string} character
|
|
508
|
+
* @returns {number}
|
|
509
|
+
*/
|
|
510
|
+
static #measureCharacterWidth(character) {
|
|
511
|
+
if (/\s/.test(character)) return 0.32
|
|
512
|
+
if (/[.,;:!|]/.test(character)) return 0.24
|
|
513
|
+
if (/[()[\]{}]/.test(character)) return 0.32
|
|
514
|
+
if (/[-+/\\]/.test(character)) return 0.36
|
|
515
|
+
if (/[MW@#%&]/.test(character)) return 0.82
|
|
516
|
+
if (/[A-Z]/.test(character)) return 0.62
|
|
517
|
+
if (/[a-z0-9]/.test(character)) return 0.5
|
|
518
|
+
if (/[^ -~]/.test(character)) return 0.92
|
|
519
|
+
|
|
520
|
+
return 0.56
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Returns true when two axis-aligned bounds overlap.
|
|
525
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} left
|
|
526
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} right
|
|
527
|
+
* @returns {boolean}
|
|
528
|
+
*/
|
|
529
|
+
static #boundsOverlap(left, right) {
|
|
530
|
+
return !(
|
|
531
|
+
left.maxX < right.minX ||
|
|
532
|
+
left.minX > right.maxX ||
|
|
533
|
+
left.maxY < right.minY ||
|
|
534
|
+
left.minY > right.maxY
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Clips one axis-aligned bounds object to an enclosing window.
|
|
540
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
541
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} window
|
|
542
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
|
|
543
|
+
*/
|
|
544
|
+
static #clipBoundsToWindow(bounds, window) {
|
|
545
|
+
return {
|
|
546
|
+
minX: Math.max(bounds.minX, window.minX),
|
|
547
|
+
minY: Math.max(bounds.minY, window.minY),
|
|
548
|
+
maxX: Math.min(bounds.maxX, window.maxX),
|
|
549
|
+
maxY: Math.min(bounds.maxY, window.maxY)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Returns true when two line segments touch at any endpoint.
|
|
555
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} left
|
|
556
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} right
|
|
557
|
+
* @returns {boolean}
|
|
558
|
+
*/
|
|
559
|
+
static #linesTouch(left, right) {
|
|
560
|
+
return (
|
|
561
|
+
SchematicStandaloneCalloutNormalizer.#pointsMatch(
|
|
562
|
+
{ x: left.x1, y: left.y1 },
|
|
563
|
+
{ x: right.x1, y: right.y1 }
|
|
564
|
+
) ||
|
|
565
|
+
SchematicStandaloneCalloutNormalizer.#pointsMatch(
|
|
566
|
+
{ x: left.x1, y: left.y1 },
|
|
567
|
+
{ x: right.x2, y: right.y2 }
|
|
568
|
+
) ||
|
|
569
|
+
SchematicStandaloneCalloutNormalizer.#pointsMatch(
|
|
570
|
+
{ x: left.x2, y: left.y2 },
|
|
571
|
+
{ x: right.x1, y: right.y1 }
|
|
572
|
+
) ||
|
|
573
|
+
SchematicStandaloneCalloutNormalizer.#pointsMatch(
|
|
574
|
+
{ x: left.x2, y: left.y2 },
|
|
575
|
+
{ x: right.x2, y: right.y2 }
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Returns true when two points coincide within schematic tolerance.
|
|
582
|
+
* @param {{ x: number, y: number }} left
|
|
583
|
+
* @param {{ x: number, y: number }} right
|
|
584
|
+
* @returns {boolean}
|
|
585
|
+
*/
|
|
586
|
+
static #pointsMatch(left, right) {
|
|
587
|
+
return (
|
|
588
|
+
Math.abs(Number(left.x) - Number(right.x)) <= 2 &&
|
|
589
|
+
Math.abs(Number(left.y) - Number(right.y)) <= 2
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
}
|