altium-toolkit 1.0.2 → 1.0.8
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/package.json +1 -1
- package/src/core/altium/AltiumParser.mjs +17 -6
- package/src/core/altium/AsciiRecordParser.mjs +28 -1
- package/src/core/altium/SchematicPinDesignatorInferer.mjs +36 -3
- package/src/core/altium/SchematicPinParser.mjs +139 -14
- package/src/core/altium/SchematicPrimitiveParser.mjs +75 -4
- package/src/core/altium/SchematicTextParser.mjs +22 -18
- package/src/core/altium/SchematicTextPostProcessor.mjs +127 -10
- package/src/core/altium/SchematicWireNormalizer.mjs +1162 -0
- package/src/renderers.mjs +3 -0
- package/src/styles/altium-renderers.css +6 -0
- package/src/ui/PcbInteractionGeometry.mjs +350 -0
- package/src/ui/PcbInteractionIndex.mjs +588 -0
- package/src/ui/PcbInteractionItemRegistry.mjs +66 -0
- package/src/ui/PcbInteractionLayerModel.mjs +99 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +74 -9
- package/src/ui/PcbScene3dBuilder.mjs +32 -4
- package/src/ui/PcbSvgRenderer.mjs +2 -2
- package/src/ui/PcbTextPrimitiveRenderer.mjs +58 -2
- package/src/ui/SchematicContentLayout.mjs +124 -22
- package/src/ui/SchematicJunctionRenderer.mjs +21 -3
- package/src/ui/SchematicNoteRenderer.mjs +75 -10
- package/src/ui/SchematicPinSvgRenderer.mjs +48 -7
- package/src/ui/SchematicPowerPortRenderer.mjs +53 -160
- package/src/ui/SchematicSheetChromeRenderer.mjs +29 -15
- package/src/ui/SchematicSvgRenderer.mjs +341 -39
- package/src/ui/SchematicSvgUtils.mjs +9 -2
- package/src/ui/SchematicTypography.mjs +13 -10
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes recovered schematic wire geometry after primitive parsing.
|
|
7
|
+
*/
|
|
8
|
+
export class SchematicWireNormalizer {
|
|
9
|
+
static #MAX_COLLAPSED_PIN_SPAN = 60
|
|
10
|
+
static #MAX_CALLOUT_LEADER_SPAN = 40
|
|
11
|
+
static #CALLOUT_ARROWHEAD_SCALE = 1.25
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Corrects standalone callout arrowhead triangles whose final coordinate
|
|
15
|
+
* was carried from the previous point instead of reflected across the
|
|
16
|
+
* leader direction.
|
|
17
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[]} lines
|
|
18
|
+
* @param {{ points: { x: number, y: number }[], isSolid?: boolean, transparent?: boolean, ownerIndex?: string, renderOrder?: number }[]} polygons
|
|
19
|
+
* @returns {{ lines: { x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[], polygons: { points: { x: number, y: number }[], isSolid?: boolean, transparent?: boolean, ownerIndex?: string, renderOrder?: number }[] }}
|
|
20
|
+
*/
|
|
21
|
+
static normalizeStandaloneCalloutArrowheads(lines, polygons) {
|
|
22
|
+
const updates = []
|
|
23
|
+
const normalizedPolygons = polygons.map((polygon) => {
|
|
24
|
+
const normalizedPoints =
|
|
25
|
+
SchematicWireNormalizer.#resolveStandaloneCalloutArrowheadPoints(
|
|
26
|
+
polygon,
|
|
27
|
+
lines
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if (!normalizedPoints) {
|
|
31
|
+
return polygon
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
updates.push({
|
|
35
|
+
renderOrder: polygon.renderOrder,
|
|
36
|
+
points: normalizedPoints
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
...polygon,
|
|
41
|
+
points: normalizedPoints
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
lines: lines.map((line) =>
|
|
47
|
+
SchematicWireNormalizer.#normalizeCalloutArrowheadOutlineLine(
|
|
48
|
+
line,
|
|
49
|
+
updates
|
|
50
|
+
)
|
|
51
|
+
),
|
|
52
|
+
polygons: normalizedPolygons
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extends collapsed final wire segments to nearby pin endpoints when an
|
|
58
|
+
* omitted coordinate axis made the segment degenerate.
|
|
59
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }[]} lines
|
|
60
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
61
|
+
* @param {{ x: number, y: number }[]} [junctions]
|
|
62
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]}
|
|
63
|
+
*/
|
|
64
|
+
static extendCollapsedPolylineEndpoints(lines, pins, junctions = []) {
|
|
65
|
+
const calloutLines =
|
|
66
|
+
SchematicWireNormalizer.#restoreStandaloneCalloutLeaderEndpoints(
|
|
67
|
+
lines
|
|
68
|
+
)
|
|
69
|
+
const crossedLines =
|
|
70
|
+
SchematicWireNormalizer.#restoreCrossedPolylineEndpoints(
|
|
71
|
+
calloutLines,
|
|
72
|
+
pins
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return crossedLines
|
|
76
|
+
.map((line, index) => {
|
|
77
|
+
const extension =
|
|
78
|
+
SchematicWireNormalizer.#resolveCollapsedEndpoint(
|
|
79
|
+
line,
|
|
80
|
+
index,
|
|
81
|
+
crossedLines,
|
|
82
|
+
pins,
|
|
83
|
+
junctions
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if (!extension) {
|
|
87
|
+
return line
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
...line,
|
|
92
|
+
x2: extension.x,
|
|
93
|
+
y2: extension.y
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
.map((line) => SchematicWireNormalizer.#stripRecoveryMetadata(line))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Restores the omitted endpoint axis on standalone dashed callout leaders
|
|
101
|
+
* by snapping the leader endpoint to a nearby standalone dashed frame
|
|
102
|
+
* corner.
|
|
103
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number, omittedEndpointAxis?: 'x' | 'y' }[]} lines
|
|
104
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number, omittedEndpointAxis?: 'x' | 'y' }[]}
|
|
105
|
+
*/
|
|
106
|
+
static #restoreStandaloneCalloutLeaderEndpoints(lines) {
|
|
107
|
+
return lines.map((line, index) => {
|
|
108
|
+
const endpoint =
|
|
109
|
+
SchematicWireNormalizer.#resolveStandaloneCalloutLeaderEndpoint(
|
|
110
|
+
line,
|
|
111
|
+
index,
|
|
112
|
+
lines
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (!endpoint) {
|
|
116
|
+
return line
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
...line,
|
|
121
|
+
x2: endpoint.x,
|
|
122
|
+
y2: endpoint.y
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolves corrected points for one standalone callout arrowhead.
|
|
129
|
+
* @param {{ points: { x: number, y: number }[], isSolid?: boolean, transparent?: boolean, ownerIndex?: string, renderOrder?: number }} polygon
|
|
130
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[]} lines
|
|
131
|
+
* @returns {{ x: number, y: number }[] | null}
|
|
132
|
+
*/
|
|
133
|
+
static #resolveStandaloneCalloutArrowheadPoints(polygon, lines) {
|
|
134
|
+
if (!SchematicWireNormalizer.#isStandaloneArrowheadPolygon(polygon)) {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const [tip, firstBasePoint, carriedBasePoint] = polygon.points
|
|
139
|
+
const leaderLine =
|
|
140
|
+
SchematicWireNormalizer.#findStandaloneArrowheadLeaderLine(
|
|
141
|
+
polygon,
|
|
142
|
+
tip,
|
|
143
|
+
lines
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if (!leaderLine) {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const leaderEnd = SchematicWireNormalizer.#resolveOtherEndpoint(
|
|
151
|
+
leaderLine,
|
|
152
|
+
tip
|
|
153
|
+
)
|
|
154
|
+
if (!leaderEnd) {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const reflectedPoint = SchematicWireNormalizer.#reflectPointAcrossLine(
|
|
159
|
+
firstBasePoint,
|
|
160
|
+
tip,
|
|
161
|
+
leaderEnd
|
|
162
|
+
)
|
|
163
|
+
const recoveredBasePoint =
|
|
164
|
+
SchematicWireNormalizer.#resolveRecoveredArrowheadBasePoint(
|
|
165
|
+
firstBasePoint,
|
|
166
|
+
carriedBasePoint,
|
|
167
|
+
reflectedPoint
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if (!recoveredBasePoint) {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return SchematicWireNormalizer.#expandStandaloneArrowheadPoints(
|
|
175
|
+
tip,
|
|
176
|
+
firstBasePoint,
|
|
177
|
+
recoveredBasePoint
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Expands recovered standalone callout arrowheads from the raw truncated
|
|
183
|
+
* polygon dimensions to Altium's rendered arrowhead size.
|
|
184
|
+
* @param {{ x: number, y: number }} tip
|
|
185
|
+
* @param {{ x: number, y: number }} firstBasePoint
|
|
186
|
+
* @param {{ x: number, y: number }} secondBasePoint
|
|
187
|
+
* @returns {{ x: number, y: number }[]}
|
|
188
|
+
*/
|
|
189
|
+
static #expandStandaloneArrowheadPoints(
|
|
190
|
+
tip,
|
|
191
|
+
firstBasePoint,
|
|
192
|
+
secondBasePoint
|
|
193
|
+
) {
|
|
194
|
+
return [
|
|
195
|
+
tip,
|
|
196
|
+
SchematicWireNormalizer.#scalePointFromTip(
|
|
197
|
+
tip,
|
|
198
|
+
firstBasePoint,
|
|
199
|
+
SchematicWireNormalizer.#CALLOUT_ARROWHEAD_SCALE
|
|
200
|
+
),
|
|
201
|
+
SchematicWireNormalizer.#scalePointFromTip(
|
|
202
|
+
tip,
|
|
203
|
+
secondBasePoint,
|
|
204
|
+
SchematicWireNormalizer.#CALLOUT_ARROWHEAD_SCALE
|
|
205
|
+
)
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Scales one point away from an arrowhead tip.
|
|
211
|
+
* @param {{ x: number, y: number }} tip
|
|
212
|
+
* @param {{ x: number, y: number }} point
|
|
213
|
+
* @param {number} scale
|
|
214
|
+
* @returns {{ x: number, y: number }}
|
|
215
|
+
*/
|
|
216
|
+
static #scalePointFromTip(tip, point, scale) {
|
|
217
|
+
return {
|
|
218
|
+
x: SchematicWireNormalizer.#normalizeRecoveredCoordinate(
|
|
219
|
+
tip.x + (point.x - tip.x) * scale
|
|
220
|
+
),
|
|
221
|
+
y: SchematicWireNormalizer.#normalizeRecoveredCoordinate(
|
|
222
|
+
tip.y + (point.y - tip.y) * scale
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Returns true when a polygon has the standalone filled triangle shape used
|
|
229
|
+
* by note callout arrowheads.
|
|
230
|
+
* @param {{ points: { x: number, y: number }[], isSolid?: boolean, transparent?: boolean, ownerIndex?: string }} polygon
|
|
231
|
+
* @returns {boolean}
|
|
232
|
+
*/
|
|
233
|
+
static #isStandaloneArrowheadPolygon(polygon) {
|
|
234
|
+
return (
|
|
235
|
+
!polygon.ownerIndex &&
|
|
236
|
+
polygon.isSolid === true &&
|
|
237
|
+
polygon.transparent !== true &&
|
|
238
|
+
polygon.points?.length === 3
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Finds the diagonal dashed leader touching one arrowhead tip.
|
|
244
|
+
* @param {{ renderOrder?: number }} polygon
|
|
245
|
+
* @param {{ x: number, y: number }} tip
|
|
246
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[]} lines
|
|
247
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number } | null}
|
|
248
|
+
*/
|
|
249
|
+
static #findStandaloneArrowheadLeaderLine(polygon, tip, lines) {
|
|
250
|
+
const candidates = lines
|
|
251
|
+
.filter(
|
|
252
|
+
(line) =>
|
|
253
|
+
line.recordType === '6' &&
|
|
254
|
+
!line.ownerIndex &&
|
|
255
|
+
line.lineStyle === 1 &&
|
|
256
|
+
SchematicWireNormalizer.#isDiagonalLine(line) &&
|
|
257
|
+
SchematicWireNormalizer.#lineTouchesPoint(line, tip)
|
|
258
|
+
)
|
|
259
|
+
.map((line) => ({
|
|
260
|
+
line,
|
|
261
|
+
orderDistance: Math.abs(
|
|
262
|
+
Number(polygon.renderOrder || 0) -
|
|
263
|
+
Number(line.renderOrder || 0)
|
|
264
|
+
)
|
|
265
|
+
}))
|
|
266
|
+
.filter(({ orderDistance }) => orderDistance <= 2)
|
|
267
|
+
.sort((left, right) => left.orderDistance - right.orderDistance)
|
|
268
|
+
|
|
269
|
+
return candidates[0]?.line || null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolves the missing second base point from the reflected counterpart.
|
|
274
|
+
* @param {{ x: number, y: number }} firstBasePoint
|
|
275
|
+
* @param {{ x: number, y: number }} carriedBasePoint
|
|
276
|
+
* @param {{ x: number, y: number }} reflectedPoint
|
|
277
|
+
* @returns {{ x: number, y: number } | null}
|
|
278
|
+
*/
|
|
279
|
+
static #resolveRecoveredArrowheadBasePoint(
|
|
280
|
+
firstBasePoint,
|
|
281
|
+
carriedBasePoint,
|
|
282
|
+
reflectedPoint
|
|
283
|
+
) {
|
|
284
|
+
if (
|
|
285
|
+
firstBasePoint.y === carriedBasePoint.y &&
|
|
286
|
+
SchematicWireNormalizer.#nearlyEqual(
|
|
287
|
+
reflectedPoint.x,
|
|
288
|
+
carriedBasePoint.x
|
|
289
|
+
)
|
|
290
|
+
) {
|
|
291
|
+
return {
|
|
292
|
+
x: carriedBasePoint.x,
|
|
293
|
+
y: SchematicWireNormalizer.#normalizeRecoveredCoordinate(
|
|
294
|
+
reflectedPoint.y
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (
|
|
300
|
+
firstBasePoint.x === carriedBasePoint.x &&
|
|
301
|
+
SchematicWireNormalizer.#nearlyEqual(
|
|
302
|
+
reflectedPoint.y,
|
|
303
|
+
carriedBasePoint.y
|
|
304
|
+
)
|
|
305
|
+
) {
|
|
306
|
+
return {
|
|
307
|
+
x: SchematicWireNormalizer.#normalizeRecoveredCoordinate(
|
|
308
|
+
reflectedPoint.x
|
|
309
|
+
),
|
|
310
|
+
y: carriedBasePoint.y
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return null
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Mirrors one point across a leader axis.
|
|
319
|
+
* @param {{ x: number, y: number }} point
|
|
320
|
+
* @param {{ x: number, y: number }} lineStart
|
|
321
|
+
* @param {{ x: number, y: number }} lineEnd
|
|
322
|
+
* @returns {{ x: number, y: number }}
|
|
323
|
+
*/
|
|
324
|
+
static #reflectPointAcrossLine(point, lineStart, lineEnd) {
|
|
325
|
+
const dx = lineEnd.x - lineStart.x
|
|
326
|
+
const dy = lineEnd.y - lineStart.y
|
|
327
|
+
const lengthSquared = dx * dx + dy * dy
|
|
328
|
+
|
|
329
|
+
if (lengthSquared <= 0) {
|
|
330
|
+
return point
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const projectionScale =
|
|
334
|
+
((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) /
|
|
335
|
+
lengthSquared
|
|
336
|
+
const projection = {
|
|
337
|
+
x: lineStart.x + projectionScale * dx,
|
|
338
|
+
y: lineStart.y + projectionScale * dy
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
x: projection.x * 2 - point.x,
|
|
343
|
+
y: projection.y * 2 - point.y
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Updates one matching record-7 outline segment to the normalized arrowhead
|
|
349
|
+
* points.
|
|
350
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, recordType?: string, renderOrder?: number }} line
|
|
351
|
+
* @param {{ renderOrder?: number, points: { x: number, y: number }[] }[]} updates
|
|
352
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, recordType?: string, renderOrder?: number }}
|
|
353
|
+
*/
|
|
354
|
+
static #normalizeCalloutArrowheadOutlineLine(line, updates) {
|
|
355
|
+
for (const update of updates) {
|
|
356
|
+
const segmentIndex =
|
|
357
|
+
SchematicWireNormalizer.#resolveArrowheadOutlineSegmentIndex(
|
|
358
|
+
line,
|
|
359
|
+
update.renderOrder
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if (segmentIndex === null) {
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const startPoint = update.points[segmentIndex]
|
|
367
|
+
const endPoint = update.points[(segmentIndex + 1) % 3]
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
...line,
|
|
371
|
+
x1: startPoint.x,
|
|
372
|
+
y1: startPoint.y,
|
|
373
|
+
x2: endPoint.x,
|
|
374
|
+
y2: endPoint.y
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return line
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Resolves which outline segment a record-7 line belongs to.
|
|
383
|
+
* @param {{ recordType?: string, renderOrder?: number }} line
|
|
384
|
+
* @param {number | undefined} polygonRenderOrder
|
|
385
|
+
* @returns {number | null}
|
|
386
|
+
*/
|
|
387
|
+
static #resolveArrowheadOutlineSegmentIndex(line, polygonRenderOrder) {
|
|
388
|
+
if (
|
|
389
|
+
line.recordType !== '7' ||
|
|
390
|
+
!Number.isFinite(line.renderOrder) ||
|
|
391
|
+
!Number.isFinite(polygonRenderOrder)
|
|
392
|
+
) {
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const segmentIndex = Math.round(
|
|
397
|
+
(line.renderOrder - polygonRenderOrder) * 100
|
|
398
|
+
)
|
|
399
|
+
const expectedRenderOrder = polygonRenderOrder + segmentIndex / 100
|
|
400
|
+
|
|
401
|
+
if (
|
|
402
|
+
segmentIndex < 0 ||
|
|
403
|
+
segmentIndex >= 3 ||
|
|
404
|
+
!SchematicWireNormalizer.#nearlyEqual(
|
|
405
|
+
line.renderOrder,
|
|
406
|
+
expectedRenderOrder
|
|
407
|
+
)
|
|
408
|
+
) {
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return segmentIndex
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Resolves the nearby dashed frame endpoint for one callout leader.
|
|
417
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number, omittedEndpointAxis?: 'x' | 'y' }} line
|
|
418
|
+
* @param {number} index
|
|
419
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[]} lines
|
|
420
|
+
* @returns {{ x: number, y: number } | null}
|
|
421
|
+
*/
|
|
422
|
+
static #resolveStandaloneCalloutLeaderEndpoint(line, index, lines) {
|
|
423
|
+
if (!SchematicWireNormalizer.#isStandaloneCalloutLeader(line)) {
|
|
424
|
+
return null
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const carriedEndpoint = { x: line.x2, y: line.y2 }
|
|
428
|
+
const startPoint = { x: line.x1, y: line.y1 }
|
|
429
|
+
const candidates = []
|
|
430
|
+
|
|
431
|
+
for (
|
|
432
|
+
let candidateIndex = 0;
|
|
433
|
+
candidateIndex < lines.length;
|
|
434
|
+
candidateIndex += 1
|
|
435
|
+
) {
|
|
436
|
+
if (candidateIndex === index) {
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const frameLine = lines[candidateIndex]
|
|
441
|
+
if (
|
|
442
|
+
!SchematicWireNormalizer.#isStandaloneCalloutFrameLine(
|
|
443
|
+
frameLine,
|
|
444
|
+
line
|
|
445
|
+
)
|
|
446
|
+
) {
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (const framePoint of [
|
|
451
|
+
{ x: frameLine.x1, y: frameLine.y1 },
|
|
452
|
+
{ x: frameLine.x2, y: frameLine.y2 }
|
|
453
|
+
]) {
|
|
454
|
+
const endpoint =
|
|
455
|
+
SchematicWireNormalizer.#buildCalloutLeaderEndpoint(
|
|
456
|
+
line,
|
|
457
|
+
carriedEndpoint,
|
|
458
|
+
framePoint
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if (
|
|
462
|
+
!endpoint ||
|
|
463
|
+
!SchematicWireNormalizer.#isDiagonalBetween(
|
|
464
|
+
startPoint,
|
|
465
|
+
endpoint
|
|
466
|
+
)
|
|
467
|
+
) {
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const distance = SchematicWireNormalizer.#axisDistance(
|
|
472
|
+
carriedEndpoint,
|
|
473
|
+
endpoint
|
|
474
|
+
)
|
|
475
|
+
if (
|
|
476
|
+
distance > SchematicWireNormalizer.#MAX_CALLOUT_LEADER_SPAN
|
|
477
|
+
) {
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
candidates.push({
|
|
482
|
+
endpoint,
|
|
483
|
+
distance,
|
|
484
|
+
orderDistance: Math.abs(
|
|
485
|
+
Number(line.renderOrder || 0) -
|
|
486
|
+
Number(frameLine.renderOrder || 0)
|
|
487
|
+
)
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
candidates.sort(
|
|
493
|
+
(left, right) =>
|
|
494
|
+
left.distance - right.distance ||
|
|
495
|
+
left.orderDistance - right.orderDistance
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return candidates[0]?.endpoint || null
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Builds a recovered leader endpoint from one candidate frame point.
|
|
503
|
+
* @param {{ omittedEndpointAxis?: 'x' | 'y' }} line
|
|
504
|
+
* @param {{ x: number, y: number }} carriedEndpoint
|
|
505
|
+
* @param {{ x: number, y: number }} framePoint
|
|
506
|
+
* @returns {{ x: number, y: number } | null}
|
|
507
|
+
*/
|
|
508
|
+
static #buildCalloutLeaderEndpoint(line, carriedEndpoint, framePoint) {
|
|
509
|
+
if (
|
|
510
|
+
line.omittedEndpointAxis === 'y' &&
|
|
511
|
+
framePoint.x === carriedEndpoint.x &&
|
|
512
|
+
framePoint.y !== carriedEndpoint.y
|
|
513
|
+
) {
|
|
514
|
+
return { x: carriedEndpoint.x, y: framePoint.y }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (
|
|
518
|
+
line.omittedEndpointAxis === 'x' &&
|
|
519
|
+
framePoint.y === carriedEndpoint.y &&
|
|
520
|
+
framePoint.x !== carriedEndpoint.x
|
|
521
|
+
) {
|
|
522
|
+
return { x: framePoint.x, y: carriedEndpoint.y }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return null
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Returns true when a record-6 line looks like an unowned dashed callout
|
|
530
|
+
* leader whose endpoint carried an omitted coordinate.
|
|
531
|
+
* @param {{ ownerIndex?: string, recordType?: string, lineStyle?: number, omittedEndpointAxis?: 'x' | 'y', sourceLocationCount?: number }} line
|
|
532
|
+
* @returns {boolean}
|
|
533
|
+
*/
|
|
534
|
+
static #isStandaloneCalloutLeader(line) {
|
|
535
|
+
return (
|
|
536
|
+
line.recordType === '6' &&
|
|
537
|
+
!line.ownerIndex &&
|
|
538
|
+
line.lineStyle === 1 &&
|
|
539
|
+
line.sourceLocationCount === 2 &&
|
|
540
|
+
Boolean(line.omittedEndpointAxis)
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Returns true when one record-6 line can supply a dashed callout frame
|
|
546
|
+
* corner for the candidate leader.
|
|
547
|
+
* @param {{ ownerIndex?: string, recordType?: string, lineStyle?: number }} frameLine
|
|
548
|
+
* @param {{ lineStyle?: number }} leaderLine
|
|
549
|
+
* @returns {boolean}
|
|
550
|
+
*/
|
|
551
|
+
static #isStandaloneCalloutFrameLine(frameLine, leaderLine) {
|
|
552
|
+
return (
|
|
553
|
+
frameLine.recordType === '6' &&
|
|
554
|
+
!frameLine.ownerIndex &&
|
|
555
|
+
frameLine.lineStyle === leaderLine.lineStyle &&
|
|
556
|
+
!SchematicWireNormalizer.#isCollapsedLine(frameLine)
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Restores a carried final axis when paired diagonal wire legs prove the
|
|
562
|
+
* endpoint should land on a neighboring pin instead of flattening.
|
|
563
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }[]} lines
|
|
564
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
565
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }[]}
|
|
566
|
+
*/
|
|
567
|
+
static #restoreCrossedPolylineEndpoints(lines, pins) {
|
|
568
|
+
const pinEndpoints = pins.map((pin) =>
|
|
569
|
+
SchematicWireNormalizer.#projectPinEndpoint(pin)
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
return lines.map((line, index) => {
|
|
573
|
+
const startPoint =
|
|
574
|
+
SchematicWireNormalizer.#resolveCrossedPolylineEndpoint(
|
|
575
|
+
line,
|
|
576
|
+
index,
|
|
577
|
+
lines,
|
|
578
|
+
pinEndpoints,
|
|
579
|
+
'start'
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if (startPoint) {
|
|
583
|
+
return { ...line, x1: startPoint.x, y1: startPoint.y }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const endPoint =
|
|
587
|
+
SchematicWireNormalizer.#resolveCrossedPolylineEndpoint(
|
|
588
|
+
line,
|
|
589
|
+
index,
|
|
590
|
+
lines,
|
|
591
|
+
pinEndpoints,
|
|
592
|
+
'end'
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
if (endPoint) {
|
|
596
|
+
return { ...line, x2: endPoint.x, y2: endPoint.y }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return line
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Resolves one endpoint of a flattened crossed-wire segment.
|
|
605
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }} line
|
|
606
|
+
* @param {number} index
|
|
607
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]} lines
|
|
608
|
+
* @param {{ x: number, y: number }[]} pinEndpoints
|
|
609
|
+
* @param {'start' | 'end'} terminal
|
|
610
|
+
* @returns {{ x: number, y: number } | null}
|
|
611
|
+
*/
|
|
612
|
+
static #resolveCrossedPolylineEndpoint(
|
|
613
|
+
line,
|
|
614
|
+
index,
|
|
615
|
+
lines,
|
|
616
|
+
pinEndpoints,
|
|
617
|
+
terminal
|
|
618
|
+
) {
|
|
619
|
+
if (
|
|
620
|
+
!SchematicWireNormalizer.#isUnownedWire(line) ||
|
|
621
|
+
!line.omittedEndpointAxis ||
|
|
622
|
+
SchematicWireNormalizer.#isCollapsedLine(line)
|
|
623
|
+
) {
|
|
624
|
+
return null
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const axis = SchematicWireNormalizer.#resolveLineAxis(line)
|
|
628
|
+
|
|
629
|
+
if (!axis) {
|
|
630
|
+
return null
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const carriedPoint =
|
|
634
|
+
terminal === 'start'
|
|
635
|
+
? { x: line.x1, y: line.y1 }
|
|
636
|
+
: { x: line.x2, y: line.y2 }
|
|
637
|
+
const fixedPoint =
|
|
638
|
+
terminal === 'start'
|
|
639
|
+
? { x: line.x2, y: line.y2 }
|
|
640
|
+
: { x: line.x1, y: line.y1 }
|
|
641
|
+
|
|
642
|
+
for (
|
|
643
|
+
let candidateIndex = 0;
|
|
644
|
+
candidateIndex < lines.length;
|
|
645
|
+
candidateIndex += 1
|
|
646
|
+
) {
|
|
647
|
+
if (candidateIndex === index) {
|
|
648
|
+
continue
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const diagonalLine = lines[candidateIndex]
|
|
652
|
+
|
|
653
|
+
if (
|
|
654
|
+
!SchematicWireNormalizer.#isUnownedWire(diagonalLine) ||
|
|
655
|
+
!SchematicWireNormalizer.#isDiagonalLine(diagonalLine)
|
|
656
|
+
) {
|
|
657
|
+
continue
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const diagonalPoint = SchematicWireNormalizer.#resolveOtherEndpoint(
|
|
661
|
+
diagonalLine,
|
|
662
|
+
carriedPoint
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
if (!diagonalPoint) {
|
|
666
|
+
continue
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const recoveredPoint =
|
|
670
|
+
axis === 'horizontal'
|
|
671
|
+
? { x: carriedPoint.x, y: diagonalPoint.y }
|
|
672
|
+
: { x: diagonalPoint.x, y: carriedPoint.y }
|
|
673
|
+
|
|
674
|
+
if (
|
|
675
|
+
SchematicWireNormalizer.#pointsEqual(
|
|
676
|
+
recoveredPoint,
|
|
677
|
+
carriedPoint
|
|
678
|
+
) ||
|
|
679
|
+
!SchematicWireNormalizer.#isDiagonalBetween(
|
|
680
|
+
fixedPoint,
|
|
681
|
+
recoveredPoint
|
|
682
|
+
) ||
|
|
683
|
+
!SchematicWireNormalizer.#hasPoint(pinEndpoints, recoveredPoint)
|
|
684
|
+
) {
|
|
685
|
+
continue
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return recoveredPoint
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return null
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Resolves the endpoint for one collapsed wire segment.
|
|
696
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }} line
|
|
697
|
+
* @param {number} index
|
|
698
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]} lines
|
|
699
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
700
|
+
* @param {{ x: number, y: number }[]} junctions
|
|
701
|
+
* @returns {{ x: number, y: number } | null}
|
|
702
|
+
*/
|
|
703
|
+
static #resolveCollapsedEndpoint(line, index, lines, pins, junctions) {
|
|
704
|
+
if (!SchematicWireNormalizer.#isCollapsedWire(line)) {
|
|
705
|
+
return null
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const previousLine = lines[index - 1]
|
|
709
|
+
const sourcePoint = { x: line.x1, y: line.y1 }
|
|
710
|
+
|
|
711
|
+
if (
|
|
712
|
+
!previousLine ||
|
|
713
|
+
SchematicWireNormalizer.#isCollapsedLine(previousLine) ||
|
|
714
|
+
!SchematicWireNormalizer.#lineTouchesPoint(
|
|
715
|
+
previousLine,
|
|
716
|
+
sourcePoint
|
|
717
|
+
)
|
|
718
|
+
) {
|
|
719
|
+
return null
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const fallbackAxis =
|
|
723
|
+
SchematicWireNormalizer.#resolvePerpendicularAxis(previousLine)
|
|
724
|
+
const axes = line.omittedEndpointAxis
|
|
725
|
+
? [line.omittedEndpointAxis]
|
|
726
|
+
: fallbackAxis
|
|
727
|
+
? [fallbackAxis]
|
|
728
|
+
: ['x', 'y']
|
|
729
|
+
|
|
730
|
+
return SchematicWireNormalizer.#findNearestContinuationPoint(
|
|
731
|
+
sourcePoint,
|
|
732
|
+
axes,
|
|
733
|
+
pins,
|
|
734
|
+
lines,
|
|
735
|
+
index,
|
|
736
|
+
junctions
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Returns true when a line is an unowned collapsed wire primitive.
|
|
742
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }} line
|
|
743
|
+
* @returns {boolean}
|
|
744
|
+
*/
|
|
745
|
+
static #isCollapsedWire(line) {
|
|
746
|
+
return (
|
|
747
|
+
SchematicWireNormalizer.#isUnownedWire(line) &&
|
|
748
|
+
SchematicWireNormalizer.#isCollapsedLine(line)
|
|
749
|
+
)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Removes parser-only recovery hints before returning renderer lines.
|
|
754
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, omittedEndpointAxis?: 'x' | 'y', sourceLocationCount?: number, [key: string]: unknown }} line
|
|
755
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, [key: string]: unknown }}
|
|
756
|
+
*/
|
|
757
|
+
static #stripRecoveryMetadata(line) {
|
|
758
|
+
const { omittedEndpointAxis, sourceLocationCount, ...rendererLine } =
|
|
759
|
+
line
|
|
760
|
+
return rendererLine
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Returns true when a line is an unowned schematic wire primitive.
|
|
765
|
+
* @param {{ ownerIndex?: string, recordType?: string }} line
|
|
766
|
+
* @returns {boolean}
|
|
767
|
+
*/
|
|
768
|
+
static #isUnownedWire(line) {
|
|
769
|
+
return line.recordType === '27' && !line.ownerIndex
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Returns true when a line has no drawable length.
|
|
774
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} line
|
|
775
|
+
* @returns {boolean}
|
|
776
|
+
*/
|
|
777
|
+
static #isCollapsedLine(line) {
|
|
778
|
+
return line.x1 === line.x2 && line.y1 === line.y2
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Resolves the missing axis from the preceding segment orientation.
|
|
783
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} line
|
|
784
|
+
* @returns {'x' | 'y' | null}
|
|
785
|
+
*/
|
|
786
|
+
static #resolvePerpendicularAxis(line) {
|
|
787
|
+
if (line.y1 === line.y2 && line.x1 !== line.x2) {
|
|
788
|
+
return 'y'
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (line.x1 === line.x2 && line.y1 !== line.y2) {
|
|
792
|
+
return 'x'
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return null
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Resolves whether a line is horizontal or vertical.
|
|
800
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} line
|
|
801
|
+
* @returns {'horizontal' | 'vertical' | null}
|
|
802
|
+
*/
|
|
803
|
+
static #resolveLineAxis(line) {
|
|
804
|
+
if (line.y1 === line.y2 && line.x1 !== line.x2) {
|
|
805
|
+
return 'horizontal'
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (line.x1 === line.x2 && line.y1 !== line.y2) {
|
|
809
|
+
return 'vertical'
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return null
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Returns true when a line has both axes changing.
|
|
817
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} line
|
|
818
|
+
* @returns {boolean}
|
|
819
|
+
*/
|
|
820
|
+
static #isDiagonalLine(line) {
|
|
821
|
+
return line.x1 !== line.x2 && line.y1 !== line.y2
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Finds the nearest aligned continuation for a collapsed segment point.
|
|
826
|
+
* @param {{ x: number, y: number }} sourcePoint
|
|
827
|
+
* @param {('x' | 'y')[]} axes
|
|
828
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
|
|
829
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]} lines
|
|
830
|
+
* @param {number} currentIndex
|
|
831
|
+
* @param {{ x: number, y: number }[]} junctions
|
|
832
|
+
* @returns {{ x: number, y: number } | null}
|
|
833
|
+
*/
|
|
834
|
+
static #findNearestContinuationPoint(
|
|
835
|
+
sourcePoint,
|
|
836
|
+
axes,
|
|
837
|
+
pins,
|
|
838
|
+
lines,
|
|
839
|
+
currentIndex,
|
|
840
|
+
junctions
|
|
841
|
+
) {
|
|
842
|
+
const junctionCandidates =
|
|
843
|
+
SchematicWireNormalizer.#collectAlignedJunctionCandidates(
|
|
844
|
+
sourcePoint,
|
|
845
|
+
axes,
|
|
846
|
+
junctions
|
|
847
|
+
)
|
|
848
|
+
const pinCandidates = pins
|
|
849
|
+
.map((pin) => SchematicWireNormalizer.#projectPinEndpoint(pin))
|
|
850
|
+
.filter((endpoint) =>
|
|
851
|
+
SchematicWireNormalizer.#isAlignedWithAnyAxis(
|
|
852
|
+
sourcePoint,
|
|
853
|
+
endpoint,
|
|
854
|
+
axes
|
|
855
|
+
)
|
|
856
|
+
)
|
|
857
|
+
.map((endpoint) => ({
|
|
858
|
+
...SchematicWireNormalizer.#buildCandidate(
|
|
859
|
+
sourcePoint,
|
|
860
|
+
endpoint
|
|
861
|
+
),
|
|
862
|
+
priority: 1
|
|
863
|
+
}))
|
|
864
|
+
.filter(({ distance }) =>
|
|
865
|
+
SchematicWireNormalizer.#isRecoverableDistance(distance)
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
const lineCandidates =
|
|
869
|
+
SchematicWireNormalizer.#collectAlignedLineCandidates(
|
|
870
|
+
sourcePoint,
|
|
871
|
+
axes,
|
|
872
|
+
lines,
|
|
873
|
+
currentIndex
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
const candidates = [
|
|
877
|
+
...junctionCandidates,
|
|
878
|
+
...pinCandidates,
|
|
879
|
+
...lineCandidates
|
|
880
|
+
].sort(
|
|
881
|
+
(left, right) =>
|
|
882
|
+
left.priority - right.priority || left.distance - right.distance
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
return candidates[0]?.endpoint || null
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Collects authored junctions aligned with the missing endpoint axis.
|
|
890
|
+
* @param {{ x: number, y: number }} sourcePoint
|
|
891
|
+
* @param {('x' | 'y')[]} axes
|
|
892
|
+
* @param {{ x: number, y: number }[]} junctions
|
|
893
|
+
* @returns {{ endpoint: { x: number, y: number }, distance: number, priority: number }[]}
|
|
894
|
+
*/
|
|
895
|
+
static #collectAlignedJunctionCandidates(sourcePoint, axes, junctions) {
|
|
896
|
+
return junctions
|
|
897
|
+
.filter((junction) =>
|
|
898
|
+
SchematicWireNormalizer.#isAlignedWithAnyAxis(
|
|
899
|
+
sourcePoint,
|
|
900
|
+
junction,
|
|
901
|
+
axes
|
|
902
|
+
)
|
|
903
|
+
)
|
|
904
|
+
.map((junction) => ({
|
|
905
|
+
...SchematicWireNormalizer.#buildCandidate(
|
|
906
|
+
sourcePoint,
|
|
907
|
+
junction
|
|
908
|
+
),
|
|
909
|
+
priority: 0
|
|
910
|
+
}))
|
|
911
|
+
.filter(({ distance }) =>
|
|
912
|
+
SchematicWireNormalizer.#isRecoverableDistance(distance)
|
|
913
|
+
)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Collects nearby same-axis wire continuations for a collapsed point.
|
|
918
|
+
* @param {{ x: number, y: number }} sourcePoint
|
|
919
|
+
* @param {('x' | 'y')[]} axes
|
|
920
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]} lines
|
|
921
|
+
* @param {number} currentIndex
|
|
922
|
+
* @returns {{ endpoint: { x: number, y: number }, distance: number, priority: number }[]}
|
|
923
|
+
*/
|
|
924
|
+
static #collectAlignedLineCandidates(
|
|
925
|
+
sourcePoint,
|
|
926
|
+
axes,
|
|
927
|
+
lines,
|
|
928
|
+
currentIndex
|
|
929
|
+
) {
|
|
930
|
+
const candidates = []
|
|
931
|
+
|
|
932
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
933
|
+
if (index === currentIndex) {
|
|
934
|
+
continue
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const line = lines[index]
|
|
938
|
+
|
|
939
|
+
if (
|
|
940
|
+
!SchematicWireNormalizer.#isUnownedWire(line) ||
|
|
941
|
+
SchematicWireNormalizer.#isCollapsedLine(line)
|
|
942
|
+
) {
|
|
943
|
+
continue
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (
|
|
947
|
+
axes.includes('y') &&
|
|
948
|
+
line.y1 === line.y2 &&
|
|
949
|
+
SchematicWireNormalizer.#between(
|
|
950
|
+
sourcePoint.x,
|
|
951
|
+
line.x1,
|
|
952
|
+
line.x2
|
|
953
|
+
)
|
|
954
|
+
) {
|
|
955
|
+
candidates.push({
|
|
956
|
+
...SchematicWireNormalizer.#buildCandidate(sourcePoint, {
|
|
957
|
+
x: sourcePoint.x,
|
|
958
|
+
y: line.y1
|
|
959
|
+
}),
|
|
960
|
+
priority: 1
|
|
961
|
+
})
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (
|
|
965
|
+
axes.includes('x') &&
|
|
966
|
+
line.x1 === line.x2 &&
|
|
967
|
+
SchematicWireNormalizer.#between(
|
|
968
|
+
sourcePoint.y,
|
|
969
|
+
line.y1,
|
|
970
|
+
line.y2
|
|
971
|
+
)
|
|
972
|
+
) {
|
|
973
|
+
candidates.push({
|
|
974
|
+
...SchematicWireNormalizer.#buildCandidate(sourcePoint, {
|
|
975
|
+
x: line.x1,
|
|
976
|
+
y: sourcePoint.y
|
|
977
|
+
}),
|
|
978
|
+
priority: 1
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return candidates.filter(({ distance }) =>
|
|
984
|
+
SchematicWireNormalizer.#isRecoverableDistance(distance)
|
|
985
|
+
)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Returns a distance-scored continuation candidate.
|
|
990
|
+
* @param {{ x: number, y: number }} sourcePoint
|
|
991
|
+
* @param {{ x: number, y: number }} endpoint
|
|
992
|
+
* @returns {{ endpoint: { x: number, y: number }, distance: number }}
|
|
993
|
+
*/
|
|
994
|
+
static #buildCandidate(sourcePoint, endpoint) {
|
|
995
|
+
return {
|
|
996
|
+
endpoint,
|
|
997
|
+
distance: SchematicWireNormalizer.#axisDistance(
|
|
998
|
+
sourcePoint,
|
|
999
|
+
endpoint
|
|
1000
|
+
)
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Returns true when a point lies on any requested missing axis.
|
|
1006
|
+
* @param {{ x: number, y: number }} sourcePoint
|
|
1007
|
+
* @param {{ x: number, y: number }} endpoint
|
|
1008
|
+
* @param {('x' | 'y')[]} axes
|
|
1009
|
+
* @returns {boolean}
|
|
1010
|
+
*/
|
|
1011
|
+
static #isAlignedWithAnyAxis(sourcePoint, endpoint, axes) {
|
|
1012
|
+
return axes.some((axis) =>
|
|
1013
|
+
axis === 'y'
|
|
1014
|
+
? endpoint.x === sourcePoint.x
|
|
1015
|
+
: endpoint.y === sourcePoint.y
|
|
1016
|
+
)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Returns true when a recovery distance is useful and bounded.
|
|
1021
|
+
* @param {number} distance
|
|
1022
|
+
* @returns {boolean}
|
|
1023
|
+
*/
|
|
1024
|
+
static #isRecoverableDistance(distance) {
|
|
1025
|
+
return (
|
|
1026
|
+
distance > 0 &&
|
|
1027
|
+
distance <= SchematicWireNormalizer.#MAX_COLLAPSED_PIN_SPAN
|
|
1028
|
+
)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Returns the Manhattan distance for axis-aligned endpoint recovery.
|
|
1033
|
+
* @param {{ x: number, y: number }} sourcePoint
|
|
1034
|
+
* @param {{ x: number, y: number }} endpoint
|
|
1035
|
+
* @returns {number}
|
|
1036
|
+
*/
|
|
1037
|
+
static #axisDistance(sourcePoint, endpoint) {
|
|
1038
|
+
return (
|
|
1039
|
+
Math.abs(sourcePoint.x - endpoint.x) +
|
|
1040
|
+
Math.abs(sourcePoint.y - endpoint.y)
|
|
1041
|
+
)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Returns true when one line touches a point at either endpoint.
|
|
1046
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} line
|
|
1047
|
+
* @param {{ x: number, y: number }} point
|
|
1048
|
+
* @returns {boolean}
|
|
1049
|
+
*/
|
|
1050
|
+
static #lineTouchesPoint(line, point) {
|
|
1051
|
+
return (
|
|
1052
|
+
(line.x1 === point.x && line.y1 === point.y) ||
|
|
1053
|
+
(line.x2 === point.x && line.y2 === point.y)
|
|
1054
|
+
)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Returns the opposite endpoint if a line touches the requested point.
|
|
1059
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }} line
|
|
1060
|
+
* @param {{ x: number, y: number }} point
|
|
1061
|
+
* @returns {{ x: number, y: number } | null}
|
|
1062
|
+
*/
|
|
1063
|
+
static #resolveOtherEndpoint(line, point) {
|
|
1064
|
+
if (line.x1 === point.x && line.y1 === point.y) {
|
|
1065
|
+
return { x: line.x2, y: line.y2 }
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (line.x2 === point.x && line.y2 === point.y) {
|
|
1069
|
+
return { x: line.x1, y: line.y1 }
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
return null
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Returns true when two points share the same coordinates.
|
|
1077
|
+
* @param {{ x: number, y: number }} left
|
|
1078
|
+
* @param {{ x: number, y: number }} right
|
|
1079
|
+
* @returns {boolean}
|
|
1080
|
+
*/
|
|
1081
|
+
static #pointsEqual(left, right) {
|
|
1082
|
+
return left.x === right.x && left.y === right.y
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Returns true when two coordinates are equivalent within parser rounding
|
|
1087
|
+
* tolerance.
|
|
1088
|
+
* @param {number} left
|
|
1089
|
+
* @param {number} right
|
|
1090
|
+
* @returns {boolean}
|
|
1091
|
+
*/
|
|
1092
|
+
static #nearlyEqual(left, right) {
|
|
1093
|
+
return Math.abs(left - right) <= 1e-6
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Stabilizes recovered floating-point coordinates.
|
|
1098
|
+
* @param {number} value
|
|
1099
|
+
* @returns {number}
|
|
1100
|
+
*/
|
|
1101
|
+
static #normalizeRecoveredCoordinate(value) {
|
|
1102
|
+
const rounded = Math.round(value)
|
|
1103
|
+
|
|
1104
|
+
if (SchematicWireNormalizer.#nearlyEqual(value, rounded)) {
|
|
1105
|
+
return rounded
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
return Number(value.toFixed(3))
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Returns true when a list contains one point.
|
|
1113
|
+
* @param {{ x: number, y: number }[]} points
|
|
1114
|
+
* @param {{ x: number, y: number }} target
|
|
1115
|
+
* @returns {boolean}
|
|
1116
|
+
*/
|
|
1117
|
+
static #hasPoint(points, target) {
|
|
1118
|
+
return points.some((point) =>
|
|
1119
|
+
SchematicWireNormalizer.#pointsEqual(point, target)
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Returns true when two points form a diagonal segment.
|
|
1125
|
+
* @param {{ x: number, y: number }} left
|
|
1126
|
+
* @param {{ x: number, y: number }} right
|
|
1127
|
+
* @returns {boolean}
|
|
1128
|
+
*/
|
|
1129
|
+
static #isDiagonalBetween(left, right) {
|
|
1130
|
+
return left.x !== right.x && left.y !== right.y
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Returns true when a value lies inside an unordered inclusive range.
|
|
1135
|
+
* @param {number} value
|
|
1136
|
+
* @param {number} left
|
|
1137
|
+
* @param {number} right
|
|
1138
|
+
* @returns {boolean}
|
|
1139
|
+
*/
|
|
1140
|
+
static #between(value, left, right) {
|
|
1141
|
+
return value >= Math.min(left, right) && value <= Math.max(left, right)
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Projects one pin into its wire-connected endpoint.
|
|
1146
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
|
|
1147
|
+
* @returns {{ x: number, y: number }}
|
|
1148
|
+
*/
|
|
1149
|
+
static #projectPinEndpoint(pin) {
|
|
1150
|
+
switch (pin.orientation) {
|
|
1151
|
+
case 'right':
|
|
1152
|
+
return { x: pin.x + pin.length, y: pin.y }
|
|
1153
|
+
case 'top':
|
|
1154
|
+
return { x: pin.x, y: pin.y + pin.length }
|
|
1155
|
+
case 'bottom':
|
|
1156
|
+
return { x: pin.x, y: pin.y - pin.length }
|
|
1157
|
+
case 'left':
|
|
1158
|
+
default:
|
|
1159
|
+
return { x: pin.x - pin.length, y: pin.y }
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|