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,808 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes edge-facing footprint documentation glyphs so repeated mirrored
|
|
7
|
+
* variants open toward the nearest board edge in the 2D PCB renderer.
|
|
8
|
+
*/
|
|
9
|
+
export class PcbEdgeFacingGlyphNormalizer {
|
|
10
|
+
static #FULL_CIRCLE_EPSILON = 0.001
|
|
11
|
+
static #EDGE_GLYPH_CONNECTION_TOLERANCE = 4
|
|
12
|
+
static #EDGE_GLYPH_MIN_TRACK_COUNT = 5
|
|
13
|
+
static #EDGE_GLYPH_TIP_TRACK_LENGTH_RATIO = 0.7
|
|
14
|
+
static #EDGE_GLYPH_CENTER_TOLERANCE = 1.5
|
|
15
|
+
static #EDGE_GLYPH_PROXIMITY_RATIO = 0.2
|
|
16
|
+
static #MARKER_PROXIMITY_MULTIPLIER = 3
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalizes repeated edge-facing documentation glyphs so their opening
|
|
20
|
+
* stays on the board edge even when the authored primitive cluster is
|
|
21
|
+
* mirrored inward.
|
|
22
|
+
* @param {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }} footprintPrimitives
|
|
23
|
+
* @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} outline
|
|
24
|
+
* @param {{ preferMarkers?: boolean }} [options]
|
|
25
|
+
* @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }}
|
|
26
|
+
*/
|
|
27
|
+
static normalize(footprintPrimitives, outline, options = {}) {
|
|
28
|
+
const normalizedTracks = (footprintPrimitives?.tracks || []).map(
|
|
29
|
+
(track) => ({ ...track })
|
|
30
|
+
)
|
|
31
|
+
const normalizedArcs = (footprintPrimitives?.arcs || []).map((arc) => ({
|
|
32
|
+
...arc
|
|
33
|
+
}))
|
|
34
|
+
const groups = PcbEdgeFacingGlyphNormalizer.#collectGroups(
|
|
35
|
+
normalizedTracks,
|
|
36
|
+
normalizedArcs
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
for (const group of groups) {
|
|
40
|
+
const transform = PcbEdgeFacingGlyphNormalizer.#resolveTransform(
|
|
41
|
+
group,
|
|
42
|
+
normalizedTracks,
|
|
43
|
+
normalizedArcs
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if (!transform) {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (transform.kind === 'arc-half-flip') {
|
|
51
|
+
group.arcIndexes.forEach((arcIndex) => {
|
|
52
|
+
normalizedArcs[arcIndex] =
|
|
53
|
+
PcbEdgeFacingGlyphNormalizer.#flipArcHalf(
|
|
54
|
+
normalizedArcs[arcIndex]
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
fills: footprintPrimitives?.fills || [],
|
|
63
|
+
tracks: normalizedTracks,
|
|
64
|
+
arcs: normalizedArcs
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalizes glyphs using only the nearest board edge so 3D silkscreen
|
|
70
|
+
* detail does not overreact to nearby circular markers on other features.
|
|
71
|
+
* @param {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }} footprintPrimitives
|
|
72
|
+
* @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} outline
|
|
73
|
+
* @returns {{ fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[] }}
|
|
74
|
+
*/
|
|
75
|
+
static normalizeForBoardEdge(footprintPrimitives, outline) {
|
|
76
|
+
return PcbEdgeFacingGlyphNormalizer.normalize(
|
|
77
|
+
footprintPrimitives,
|
|
78
|
+
outline
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Collects connected non-circular footprint glyph groups that could need
|
|
84
|
+
* edge-facing orientation cleanup.
|
|
85
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width?: number }[]} tracks
|
|
86
|
+
* @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }[]} arcs
|
|
87
|
+
* @returns {{ trackIndexes: number[], arcIndexes: number[], minX: number, maxX: number, minY: number, maxY: number }[]}
|
|
88
|
+
*/
|
|
89
|
+
static #collectGroups(tracks, arcs) {
|
|
90
|
+
const items = [
|
|
91
|
+
...tracks.map((track, trackIndex) => ({
|
|
92
|
+
kind: 'track',
|
|
93
|
+
trackIndex,
|
|
94
|
+
bounds: PcbEdgeFacingGlyphNormalizer.#buildTrackBounds(track)
|
|
95
|
+
})),
|
|
96
|
+
...arcs
|
|
97
|
+
.map((arc, arcIndex) => ({
|
|
98
|
+
kind: 'arc',
|
|
99
|
+
arcIndex,
|
|
100
|
+
arc,
|
|
101
|
+
bounds: PcbEdgeFacingGlyphNormalizer.#buildArcBounds(arc)
|
|
102
|
+
}))
|
|
103
|
+
.filter(
|
|
104
|
+
(item) =>
|
|
105
|
+
!PcbEdgeFacingGlyphNormalizer.#isFullCircleArc(item.arc)
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
const visited = new Array(items.length).fill(false)
|
|
109
|
+
const groups = []
|
|
110
|
+
|
|
111
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
112
|
+
if (visited[index]) {
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const queue = [index]
|
|
117
|
+
const trackIndexes = []
|
|
118
|
+
const arcIndexes = []
|
|
119
|
+
let minX = Number.POSITIVE_INFINITY
|
|
120
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
121
|
+
let minY = Number.POSITIVE_INFINITY
|
|
122
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
123
|
+
visited[index] = true
|
|
124
|
+
|
|
125
|
+
while (queue.length) {
|
|
126
|
+
const currentIndex = queue.pop()
|
|
127
|
+
const currentItem = items[currentIndex]
|
|
128
|
+
minX = Math.min(minX, currentItem.bounds.minX)
|
|
129
|
+
maxX = Math.max(maxX, currentItem.bounds.maxX)
|
|
130
|
+
minY = Math.min(minY, currentItem.bounds.minY)
|
|
131
|
+
maxY = Math.max(maxY, currentItem.bounds.maxY)
|
|
132
|
+
|
|
133
|
+
if (currentItem.kind === 'track') {
|
|
134
|
+
trackIndexes.push(currentItem.trackIndex)
|
|
135
|
+
} else {
|
|
136
|
+
arcIndexes.push(currentItem.arcIndex)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (
|
|
140
|
+
let nextIndex = 0;
|
|
141
|
+
nextIndex < items.length;
|
|
142
|
+
nextIndex += 1
|
|
143
|
+
) {
|
|
144
|
+
if (visited[nextIndex]) {
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
PcbEdgeFacingGlyphNormalizer.#boundsIntersect(
|
|
150
|
+
currentItem.bounds,
|
|
151
|
+
items[nextIndex].bounds
|
|
152
|
+
)
|
|
153
|
+
) {
|
|
154
|
+
visited[nextIndex] = true
|
|
155
|
+
queue.push(nextIndex)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
groups.push({
|
|
161
|
+
trackIndexes,
|
|
162
|
+
arcIndexes,
|
|
163
|
+
minX,
|
|
164
|
+
maxX,
|
|
165
|
+
minY,
|
|
166
|
+
maxY
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return groups
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolves whether one connected screw glyph needs its semicircular head
|
|
175
|
+
* moved onto the same side as the screw tip while keeping the authored
|
|
176
|
+
* shaft geometry unchanged.
|
|
177
|
+
* @param {{ trackIndexes: number[], arcIndexes: number[], minX: number, maxX: number, minY: number, maxY: number }} group
|
|
178
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }[]} tracks
|
|
179
|
+
* @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }[]} arcs
|
|
180
|
+
* @returns {{ kind: 'arc-half-flip' } | null}
|
|
181
|
+
*/
|
|
182
|
+
static #resolveTransform(group, tracks, arcs) {
|
|
183
|
+
if (
|
|
184
|
+
group.trackIndexes.length <
|
|
185
|
+
PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_MIN_TRACK_COUNT ||
|
|
186
|
+
group.arcIndexes.length !== 1
|
|
187
|
+
) {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const arc = arcs[group.arcIndexes[0]]
|
|
192
|
+
if (
|
|
193
|
+
Math.abs(
|
|
194
|
+
Math.abs(
|
|
195
|
+
PcbEdgeFacingGlyphNormalizer.#resolveSweepDelta(
|
|
196
|
+
Number(arc.startAngle || 0),
|
|
197
|
+
Number(arc.endAngle || 0)
|
|
198
|
+
)
|
|
199
|
+
) - 180
|
|
200
|
+
) > PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
|
|
201
|
+
) {
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
const tipSide = PcbEdgeFacingGlyphNormalizer.#resolveTipSide(
|
|
205
|
+
group,
|
|
206
|
+
tracks,
|
|
207
|
+
arc
|
|
208
|
+
)
|
|
209
|
+
if (!tipSide) {
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
const currentHeadSide =
|
|
213
|
+
PcbEdgeFacingGlyphNormalizer.#resolveSemicircleSide(arc)
|
|
214
|
+
const desiredHeadSide = tipSide
|
|
215
|
+
|
|
216
|
+
if (!currentHeadSide || currentHeadSide === desiredHeadSide) {
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { kind: 'arc-half-flip' }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Moves one semicircular screw head onto the opposite circle half while
|
|
225
|
+
* preserving its authored endpoints and center position.
|
|
226
|
+
* @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }} arc
|
|
227
|
+
* @returns {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }}
|
|
228
|
+
*/
|
|
229
|
+
static #flipArcHalf(arc) {
|
|
230
|
+
const endAngle = Number(arc.endAngle || 0)
|
|
231
|
+
const rawDelta = endAngle - Number(arc.startAngle || 0)
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
...arc,
|
|
235
|
+
endAngle:
|
|
236
|
+
rawDelta > PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
|
|
237
|
+
? endAngle - 360
|
|
238
|
+
: endAngle + 360
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Resolves which side one screw tip points toward from the two longest
|
|
244
|
+
* tracks meeting at the head center.
|
|
245
|
+
* @param {{ trackIndexes: number[] }} group
|
|
246
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }[]} tracks
|
|
247
|
+
* @param {{ x: number, y: number, radius: number }} arc
|
|
248
|
+
* @returns {'left' | 'right' | 'top' | 'bottom' | null}
|
|
249
|
+
*/
|
|
250
|
+
static #resolveTipSide(group, tracks, arc) {
|
|
251
|
+
const centerX = Number(arc.x || 0)
|
|
252
|
+
const centerY = Number(arc.y || 0)
|
|
253
|
+
const minimumLength = Math.max(
|
|
254
|
+
Number(arc.radius || 0) *
|
|
255
|
+
PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_TIP_TRACK_LENGTH_RATIO,
|
|
256
|
+
18
|
|
257
|
+
)
|
|
258
|
+
const tipVectors = group.trackIndexes
|
|
259
|
+
.map((trackIndex) =>
|
|
260
|
+
PcbEdgeFacingGlyphNormalizer.#resolveTipVector(
|
|
261
|
+
tracks[trackIndex],
|
|
262
|
+
centerX,
|
|
263
|
+
centerY
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
.filter(Boolean)
|
|
267
|
+
.filter((vector) => vector.length >= minimumLength)
|
|
268
|
+
.sort((left, right) => right.length - left.length)
|
|
269
|
+
.slice(0, 2)
|
|
270
|
+
|
|
271
|
+
if (!tipVectors.length) {
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const averageVector = tipVectors.reduce(
|
|
276
|
+
(sum, vector) => ({
|
|
277
|
+
x: sum.x + vector.x,
|
|
278
|
+
y: sum.y + vector.y
|
|
279
|
+
}),
|
|
280
|
+
{ x: 0, y: 0 }
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if (
|
|
284
|
+
Math.abs(averageVector.x) <=
|
|
285
|
+
PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON &&
|
|
286
|
+
Math.abs(averageVector.y) <=
|
|
287
|
+
PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
|
|
288
|
+
) {
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (Math.abs(averageVector.x) >= Math.abs(averageVector.y)) {
|
|
293
|
+
return averageVector.x >= 0 ? 'right' : 'left'
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return averageVector.y >= 0 ? 'bottom' : 'top'
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Resolves one tip vector from a center-connected screw track.
|
|
301
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} track
|
|
302
|
+
* @param {number} centerX
|
|
303
|
+
* @param {number} centerY
|
|
304
|
+
* @returns {{ x: number, y: number, length: number } | null}
|
|
305
|
+
*/
|
|
306
|
+
static #resolveTipVector(track, centerX, centerY) {
|
|
307
|
+
if (!track) {
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const firstPoint = {
|
|
312
|
+
x: Number(track.x1),
|
|
313
|
+
y: Number(track.y1)
|
|
314
|
+
}
|
|
315
|
+
const secondPoint = {
|
|
316
|
+
x: Number(track.x2),
|
|
317
|
+
y: Number(track.y2)
|
|
318
|
+
}
|
|
319
|
+
const firstDistance = Math.hypot(
|
|
320
|
+
firstPoint.x - centerX,
|
|
321
|
+
firstPoint.y - centerY
|
|
322
|
+
)
|
|
323
|
+
const secondDistance = Math.hypot(
|
|
324
|
+
secondPoint.x - centerX,
|
|
325
|
+
secondPoint.y - centerY
|
|
326
|
+
)
|
|
327
|
+
const minimumDistance =
|
|
328
|
+
PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_CENTER_TOLERANCE
|
|
329
|
+
|
|
330
|
+
if (
|
|
331
|
+
firstDistance > minimumDistance &&
|
|
332
|
+
secondDistance > minimumDistance
|
|
333
|
+
) {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const farPoint =
|
|
338
|
+
firstDistance > secondDistance ? firstPoint : secondPoint
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
x: farPoint.x - centerX,
|
|
342
|
+
y: farPoint.y - centerY,
|
|
343
|
+
length: Math.hypot(farPoint.x - centerX, farPoint.y - centerY)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Resolves which circle half one semicircular screw head currently occupies.
|
|
349
|
+
* @param {{ startAngle: number, endAngle: number }} arc
|
|
350
|
+
* @returns {'left' | 'right' | 'top' | 'bottom' | null}
|
|
351
|
+
*/
|
|
352
|
+
static #resolveSemicircleSide(arc) {
|
|
353
|
+
const delta = PcbEdgeFacingGlyphNormalizer.#resolveSweepDelta(
|
|
354
|
+
Number(arc.startAngle || 0),
|
|
355
|
+
Number(arc.endAngle || 0)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if (
|
|
359
|
+
Math.abs(Math.abs(delta) - 180) >
|
|
360
|
+
PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
|
|
361
|
+
) {
|
|
362
|
+
return null
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const midpointAngle =
|
|
366
|
+
(Number(arc.startAngle || 0) + delta / 2) * (Math.PI / 180)
|
|
367
|
+
const x = Math.cos(midpointAngle)
|
|
368
|
+
const y = Math.sin(midpointAngle)
|
|
369
|
+
|
|
370
|
+
if (Math.abs(x) >= Math.abs(y)) {
|
|
371
|
+
return x >= 0 ? 'right' : 'left'
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return y >= 0 ? 'bottom' : 'top'
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Resolves whether one glyph should mirror horizontally or vertically to
|
|
379
|
+
* face its nearest board edge.
|
|
380
|
+
* @param {'left' | 'right' | 'top' | 'bottom'} edge
|
|
381
|
+
* @param {{ x: number, y: number }} arc
|
|
382
|
+
* @param {number} centerX
|
|
383
|
+
* @param {number} centerY
|
|
384
|
+
* @returns {{ axis: 'horizontal' | 'vertical', value: number } | null}
|
|
385
|
+
*/
|
|
386
|
+
static #resolveEdgeTransform(edge, arc, centerX, centerY) {
|
|
387
|
+
if (edge === 'left' && Number(arc.x) > centerX) {
|
|
388
|
+
return {
|
|
389
|
+
axis: 'horizontal',
|
|
390
|
+
value: centerX
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (edge === 'right' && Number(arc.x) < centerX) {
|
|
395
|
+
return {
|
|
396
|
+
axis: 'horizontal',
|
|
397
|
+
value: centerX
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (edge === 'top' && Number(arc.y) > centerY) {
|
|
402
|
+
return {
|
|
403
|
+
axis: 'vertical',
|
|
404
|
+
value: centerY
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (edge === 'bottom' && Number(arc.y) < centerY) {
|
|
409
|
+
return {
|
|
410
|
+
axis: 'vertical',
|
|
411
|
+
value: centerY
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return null
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Resolves the nearest adjacent full-circle marker for one glyph group.
|
|
420
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} group
|
|
421
|
+
* @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }[]} arcs
|
|
422
|
+
* @returns {{ x: number, y: number } | null}
|
|
423
|
+
*/
|
|
424
|
+
static #resolveNearestFullCircleMarker(group, arcs) {
|
|
425
|
+
const centerX = (group.minX + group.maxX) / 2
|
|
426
|
+
const centerY = (group.minY + group.maxY) / 2
|
|
427
|
+
const maxSpan = Math.max(
|
|
428
|
+
group.maxX - group.minX,
|
|
429
|
+
group.maxY - group.minY,
|
|
430
|
+
1
|
|
431
|
+
)
|
|
432
|
+
const maxDistance =
|
|
433
|
+
maxSpan * PcbEdgeFacingGlyphNormalizer.#MARKER_PROXIMITY_MULTIPLIER
|
|
434
|
+
let nearestMarker = null
|
|
435
|
+
let nearestDistance = Number.POSITIVE_INFINITY
|
|
436
|
+
|
|
437
|
+
for (const arc of arcs) {
|
|
438
|
+
if (!PcbEdgeFacingGlyphNormalizer.#isFullCircleArc(arc)) {
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const deltaX = Number(arc.x) - centerX
|
|
443
|
+
const deltaY = Number(arc.y) - centerY
|
|
444
|
+
const distance = Math.hypot(deltaX, deltaY)
|
|
445
|
+
|
|
446
|
+
if (distance > maxDistance || distance >= nearestDistance) {
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
nearestMarker = {
|
|
451
|
+
x: Number(arc.x),
|
|
452
|
+
y: Number(arc.y)
|
|
453
|
+
}
|
|
454
|
+
nearestDistance = distance
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return nearestMarker
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Resolves whether one glyph should mirror to face its adjacent full-circle
|
|
462
|
+
* marker.
|
|
463
|
+
* @param {{ x: number, y: number }} marker
|
|
464
|
+
* @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }} arc
|
|
465
|
+
* @param {number} centerX
|
|
466
|
+
* @param {number} centerY
|
|
467
|
+
* @returns {{ axis: 'horizontal' | 'vertical', value: number } | null}
|
|
468
|
+
*/
|
|
469
|
+
static #resolveMarkerTransform(marker, arc, centerX, centerY) {
|
|
470
|
+
const deltaX = Number(marker.x) - centerX
|
|
471
|
+
const deltaY = Number(marker.y) - centerY
|
|
472
|
+
|
|
473
|
+
if (Math.abs(deltaX) >= Math.abs(deltaY)) {
|
|
474
|
+
if (deltaX < 0 && Number(arc.x) > centerX) {
|
|
475
|
+
return {
|
|
476
|
+
axis: 'horizontal',
|
|
477
|
+
value: centerX
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (deltaX > 0 && Number(arc.x) < centerX) {
|
|
482
|
+
return {
|
|
483
|
+
axis: 'horizontal',
|
|
484
|
+
value: centerX
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return null
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (deltaY < 0 && Number(arc.y) > centerY) {
|
|
492
|
+
return {
|
|
493
|
+
axis: 'vertical',
|
|
494
|
+
value: centerY
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (deltaY > 0 && Number(arc.y) < centerY) {
|
|
499
|
+
return {
|
|
500
|
+
axis: 'vertical',
|
|
501
|
+
value: centerY
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return null
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Mirrors one track horizontally around a local cluster axis.
|
|
510
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }} track
|
|
511
|
+
* @param {number} axisX
|
|
512
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }}
|
|
513
|
+
*/
|
|
514
|
+
static #mirrorTrackHorizontally(track, axisX) {
|
|
515
|
+
return PcbEdgeFacingGlyphNormalizer.#normalizeTrackDirection({
|
|
516
|
+
...track,
|
|
517
|
+
x1: axisX * 2 - Number(track.x1),
|
|
518
|
+
x2: axisX * 2 - Number(track.x2)
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Mirrors one track vertically around a local cluster axis.
|
|
524
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }} track
|
|
525
|
+
* @param {number} axisY
|
|
526
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }}
|
|
527
|
+
*/
|
|
528
|
+
static #mirrorTrackVertically(track, axisY) {
|
|
529
|
+
return PcbEdgeFacingGlyphNormalizer.#normalizeTrackDirection({
|
|
530
|
+
...track,
|
|
531
|
+
y1: axisY * 2 - Number(track.y1),
|
|
532
|
+
y2: axisY * 2 - Number(track.y2)
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Mirrors one arc horizontally around a local cluster axis.
|
|
538
|
+
* @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }} arc
|
|
539
|
+
* @param {number} axisX
|
|
540
|
+
* @returns {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }}
|
|
541
|
+
*/
|
|
542
|
+
static #mirrorArcHorizontally(arc, axisX) {
|
|
543
|
+
return {
|
|
544
|
+
...arc,
|
|
545
|
+
x: axisX * 2 - Number(arc.x),
|
|
546
|
+
startAngle: PcbEdgeFacingGlyphNormalizer.#normalizeAngle(
|
|
547
|
+
180 - Number(arc.endAngle)
|
|
548
|
+
),
|
|
549
|
+
endAngle: PcbEdgeFacingGlyphNormalizer.#normalizeAngle(
|
|
550
|
+
180 - Number(arc.startAngle)
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Mirrors one arc vertically around a local cluster axis.
|
|
557
|
+
* @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }} arc
|
|
558
|
+
* @param {number} axisY
|
|
559
|
+
* @returns {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number, layerCode?: number, layerId?: number }}
|
|
560
|
+
*/
|
|
561
|
+
static #mirrorArcVertically(arc, axisY) {
|
|
562
|
+
return {
|
|
563
|
+
...arc,
|
|
564
|
+
y: axisY * 2 - Number(arc.y),
|
|
565
|
+
startAngle: PcbEdgeFacingGlyphNormalizer.#normalizeAngle(
|
|
566
|
+
360 - Number(arc.endAngle)
|
|
567
|
+
),
|
|
568
|
+
endAngle: PcbEdgeFacingGlyphNormalizer.#normalizeAngle(
|
|
569
|
+
360 - Number(arc.startAngle)
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Returns one stroke-aware bounds envelope for a documentation track.
|
|
576
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width?: number }} track
|
|
577
|
+
* @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
|
|
578
|
+
*/
|
|
579
|
+
static #buildTrackBounds(track) {
|
|
580
|
+
const halfWidth =
|
|
581
|
+
Math.max(Number(track.width || 0), 1) / 2 +
|
|
582
|
+
PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_CONNECTION_TOLERANCE
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
minX: Math.min(Number(track.x1), Number(track.x2)) - halfWidth,
|
|
586
|
+
maxX: Math.max(Number(track.x1), Number(track.x2)) + halfWidth,
|
|
587
|
+
minY: Math.min(Number(track.y1), Number(track.y2)) - halfWidth,
|
|
588
|
+
maxY: Math.max(Number(track.y1), Number(track.y2)) + halfWidth
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Returns one stroke-aware bounds envelope for a documentation arc.
|
|
594
|
+
* @param {{ x: number, y: number, radius: number, width?: number }} arc
|
|
595
|
+
* @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
|
|
596
|
+
*/
|
|
597
|
+
static #buildArcBounds(arc) {
|
|
598
|
+
const radius =
|
|
599
|
+
Math.max(Number(arc.radius || 0), 0) +
|
|
600
|
+
Math.max(Number(arc.width || 0), 1) / 2 +
|
|
601
|
+
PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_CONNECTION_TOLERANCE
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
minX: Number(arc.x) - radius,
|
|
605
|
+
maxX: Number(arc.x) + radius,
|
|
606
|
+
minY: Number(arc.y) - radius,
|
|
607
|
+
maxY: Number(arc.y) + radius
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Returns true when two stroke-aware primitive envelopes overlap.
|
|
613
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} left
|
|
614
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} right
|
|
615
|
+
* @returns {boolean}
|
|
616
|
+
*/
|
|
617
|
+
static #boundsIntersect(left, right) {
|
|
618
|
+
return !(
|
|
619
|
+
left.maxX < right.minX ||
|
|
620
|
+
left.minX > right.maxX ||
|
|
621
|
+
left.maxY < right.minY ||
|
|
622
|
+
left.minY > right.maxY
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Resolves the nearest relevant board edge for one connected glyph bounds
|
|
628
|
+
* box. Corner-adjacent glyphs use their own opening axis first so a
|
|
629
|
+
* left-edge screw near the top border still resolves against the left
|
|
630
|
+
* board edge instead of the closer top/bottom corner distance.
|
|
631
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
|
|
632
|
+
* @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} outline
|
|
633
|
+
* @param {{ x: number, y: number }} arc
|
|
634
|
+
* @param {number} centerX
|
|
635
|
+
* @param {number} centerY
|
|
636
|
+
* @returns {{ edge: 'left' | 'right' | 'top' | 'bottom', distance: number }}
|
|
637
|
+
*/
|
|
638
|
+
static #resolveNearestOutlineEdge(bounds, outline, arc, centerX, centerY) {
|
|
639
|
+
const outlineMaxX =
|
|
640
|
+
Number(outline?.minX || 0) + Number(outline?.widthMil || 0)
|
|
641
|
+
const outlineMaxY =
|
|
642
|
+
Number(outline?.minY || 0) + Number(outline?.heightMil || 0)
|
|
643
|
+
const horizontalDistances = [
|
|
644
|
+
{
|
|
645
|
+
edge: 'left',
|
|
646
|
+
distance: Math.max(bounds.minX - Number(outline?.minX || 0), 0)
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
edge: 'right',
|
|
650
|
+
distance: Math.max(outlineMaxX - bounds.maxX, 0)
|
|
651
|
+
}
|
|
652
|
+
]
|
|
653
|
+
const verticalDistances = [
|
|
654
|
+
{
|
|
655
|
+
edge: 'top',
|
|
656
|
+
distance: Math.max(bounds.minY - Number(outline?.minY || 0), 0)
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
edge: 'bottom',
|
|
660
|
+
distance: Math.max(outlineMaxY - bounds.maxY, 0)
|
|
661
|
+
}
|
|
662
|
+
]
|
|
663
|
+
|
|
664
|
+
horizontalDistances.sort(
|
|
665
|
+
(left, right) => left.distance - right.distance
|
|
666
|
+
)
|
|
667
|
+
verticalDistances.sort((left, right) => left.distance - right.distance)
|
|
668
|
+
const axis = PcbEdgeFacingGlyphNormalizer.#resolveGlyphAxis(
|
|
669
|
+
bounds,
|
|
670
|
+
arc,
|
|
671
|
+
centerX,
|
|
672
|
+
centerY
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
if (axis === 'horizontal') {
|
|
676
|
+
return horizontalDistances[0]
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (axis === 'vertical') {
|
|
680
|
+
return verticalDistances[0]
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const distances = [horizontalDistances[0], verticalDistances[0]]
|
|
684
|
+
distances.sort((left, right) => left.distance - right.distance)
|
|
685
|
+
return distances[0]
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Resolves the primary opening axis for one documentation glyph from the
|
|
690
|
+
* arc position and, when that is ambiguous, from the group's overall span.
|
|
691
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
|
|
692
|
+
* @param {{ x: number, y: number }} arc
|
|
693
|
+
* @param {number} centerX
|
|
694
|
+
* @param {number} centerY
|
|
695
|
+
* @returns {'horizontal' | 'vertical' | null}
|
|
696
|
+
*/
|
|
697
|
+
static #resolveGlyphAxis(bounds, arc, centerX, centerY) {
|
|
698
|
+
const deltaX = Math.abs(Number(arc?.x || 0) - centerX)
|
|
699
|
+
const deltaY = Math.abs(Number(arc?.y || 0) - centerY)
|
|
700
|
+
|
|
701
|
+
if (
|
|
702
|
+
deltaX >
|
|
703
|
+
deltaY + PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
|
|
704
|
+
) {
|
|
705
|
+
return 'horizontal'
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (
|
|
709
|
+
deltaY >
|
|
710
|
+
deltaX + PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
|
|
711
|
+
) {
|
|
712
|
+
return 'vertical'
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const width = Math.max(Number(bounds.maxX) - Number(bounds.minX), 0)
|
|
716
|
+
const height = Math.max(Number(bounds.maxY) - Number(bounds.minY), 0)
|
|
717
|
+
|
|
718
|
+
if (
|
|
719
|
+
width >
|
|
720
|
+
height + PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
|
|
721
|
+
) {
|
|
722
|
+
return 'horizontal'
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (
|
|
726
|
+
height >
|
|
727
|
+
width + PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
|
|
728
|
+
) {
|
|
729
|
+
return 'vertical'
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return null
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Returns true when one documentation arc is effectively a full circle.
|
|
737
|
+
* @param {{ startAngle: number, endAngle: number }} arc
|
|
738
|
+
* @returns {boolean}
|
|
739
|
+
*/
|
|
740
|
+
static #isFullCircleArc(arc) {
|
|
741
|
+
const delta = Number(arc.endAngle || 0) - Number(arc.startAngle || 0)
|
|
742
|
+
|
|
743
|
+
return (
|
|
744
|
+
Math.abs(delta) <=
|
|
745
|
+
PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON ||
|
|
746
|
+
Math.abs(delta) >=
|
|
747
|
+
360 - PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Resolves one arc sweep delta onto the short wrapped direction used by
|
|
753
|
+
* the PCB renderers.
|
|
754
|
+
* @param {number} startAngle
|
|
755
|
+
* @param {number} endAngle
|
|
756
|
+
* @returns {number}
|
|
757
|
+
*/
|
|
758
|
+
static #resolveSweepDelta(startAngle, endAngle) {
|
|
759
|
+
const rawDelta = Number(endAngle || 0) - Number(startAngle || 0)
|
|
760
|
+
let normalizedDelta = ((rawDelta + 540) % 360) - 180
|
|
761
|
+
|
|
762
|
+
if (
|
|
763
|
+
Math.abs(normalizedDelta + 180) <=
|
|
764
|
+
PcbEdgeFacingGlyphNormalizer.#FULL_CIRCLE_EPSILON &&
|
|
765
|
+
rawDelta > 0
|
|
766
|
+
) {
|
|
767
|
+
normalizedDelta = 180
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return normalizedDelta
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Normalizes one mirrored track direction into a stable left-to-right or
|
|
775
|
+
* top-to-bottom ordering for deterministic SVG output.
|
|
776
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }} track
|
|
777
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, width?: number, layerCode?: number, layerId?: number }}
|
|
778
|
+
*/
|
|
779
|
+
static #normalizeTrackDirection(track) {
|
|
780
|
+
const x1 = Number(track.x1)
|
|
781
|
+
const y1 = Number(track.y1)
|
|
782
|
+
const x2 = Number(track.x2)
|
|
783
|
+
const y2 = Number(track.y2)
|
|
784
|
+
|
|
785
|
+
if (x1 < x2 || (Math.abs(x1 - x2) <= 0.001 && y1 <= y2)) {
|
|
786
|
+
return track
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return {
|
|
790
|
+
...track,
|
|
791
|
+
x1: x2,
|
|
792
|
+
y1: y2,
|
|
793
|
+
x2: x1,
|
|
794
|
+
y2: y1
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Normalizes one angle into the range [0, 360).
|
|
800
|
+
* @param {number} angle
|
|
801
|
+
* @returns {number}
|
|
802
|
+
*/
|
|
803
|
+
static #normalizeAngle(angle) {
|
|
804
|
+
const normalized = Number(angle || 0) % 360
|
|
805
|
+
|
|
806
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
807
|
+
}
|
|
808
|
+
}
|