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.
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbInteractionIndex } from './PcbInteractionIndex.mjs'
6
+
7
+ const VIRTUAL_LAYER_DEFINITIONS = [
8
+ { key: 'tracks', label: 'Tracks' },
9
+ { key: 'vias', label: 'Vias' },
10
+ { key: 'pads', label: 'Pads' },
11
+ { key: 'holes', label: 'Holes' },
12
+ { key: 'zones', label: 'Zones' },
13
+ { key: 'footprint-text', label: 'Footprint text' }
14
+ ]
15
+
16
+ /**
17
+ * Builds a PCB layer summary with physical layers and virtual controls.
18
+ */
19
+ export class PcbInteractionLayerModel {
20
+ /**
21
+ * Resolves physical and virtual interaction layers.
22
+ * @param {object} documentModel Toolkit document model.
23
+ * @returns {{ physicalLayers: object[], virtualLayers: object[] }}
24
+ */
25
+ static resolve(documentModel) {
26
+ const pcb = documentModel?.pcb || {}
27
+ const physicalLayers = PcbInteractionLayerModel.#physicalLayers(pcb)
28
+ const items = PcbInteractionIndex.build(documentModel)
29
+ const layersByObject = PcbInteractionLayerModel.#layersByObject(items)
30
+
31
+ return {
32
+ physicalLayers,
33
+ virtualLayers: VIRTUAL_LAYER_DEFINITIONS.map((definition) => ({
34
+ ...definition,
35
+ physicalLayerKeys: Array.from(
36
+ layersByObject.get(definition.key) || []
37
+ )
38
+ }))
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolves physical layer rows from board and primitive layer metadata.
44
+ * @param {object} pcb PCB model.
45
+ * @returns {object[]}
46
+ */
47
+ static #physicalLayers(pcb) {
48
+ const seen = new Set()
49
+ const layers = []
50
+ const sources = [
51
+ ...(Array.isArray(pcb?.layers) ? pcb.layers : []),
52
+ ...(Array.isArray(pcb?.primitiveLayers) ? pcb.primitiveLayers : [])
53
+ ]
54
+
55
+ for (const layer of sources) {
56
+ const key = String(layer?.name || layer?.layer || '').trim()
57
+ if (!key || seen.has(key)) continue
58
+ seen.add(key)
59
+ layers.push({
60
+ key,
61
+ label: key,
62
+ layerId: Number.isFinite(Number(layer?.layerId))
63
+ ? Number(layer.layerId)
64
+ : null
65
+ })
66
+ }
67
+
68
+ return layers
69
+ }
70
+
71
+ /**
72
+ * Collects referenced physical layer keys by virtual object key.
73
+ * @param {object[]} items Interaction items.
74
+ * @returns {Map<string, Set<string>>}
75
+ */
76
+ static #layersByObject(items) {
77
+ const layersByObject = new Map()
78
+
79
+ for (const item of items) {
80
+ if (!layersByObject.has(item.objectKey)) {
81
+ layersByObject.set(item.objectKey, new Set())
82
+ }
83
+ const layerSet = layersByObject.get(item.objectKey)
84
+ for (const layerKey of item.layerKeys || []) {
85
+ layerSet.add(layerKey)
86
+ }
87
+ if (item.type === 'pad' || item.type === 'via') {
88
+ if (!layersByObject.has('holes')) {
89
+ layersByObject.set('holes', new Set())
90
+ }
91
+ for (const layerKey of item.layerKeys || []) {
92
+ layersByObject.get('holes').add(layerKey)
93
+ }
94
+ }
95
+ }
96
+
97
+ return layersByObject
98
+ }
99
+ }
@@ -46,17 +46,82 @@ export class PcbScene3dBoardOutlineRefiner {
46
46
  return sceneDescription
47
47
  }
48
48
 
49
+ const refinedBoard = {
50
+ ...board,
51
+ minX: candidate.minX,
52
+ minY: candidate.minY,
53
+ widthMil: candidate.widthMil,
54
+ heightMil: candidate.heightMil,
55
+ centerX: candidate.minX + candidate.widthMil / 2,
56
+ centerY: candidate.minY + candidate.heightMil / 2,
57
+ segments: candidate.segments
58
+ }
59
+
49
60
  return {
50
61
  ...sceneDescription,
51
- board: {
52
- ...board,
53
- minX: candidate.minX,
54
- minY: candidate.minY,
55
- widthMil: candidate.widthMil,
56
- heightMil: candidate.heightMil,
57
- centerX: candidate.minX + candidate.widthMil / 2,
58
- centerY: candidate.minY + candidate.heightMil / 2,
59
- segments: candidate.segments
62
+ board: refinedBoard,
63
+ components: PcbScene3dBoardOutlineRefiner.#realignLocalPlacements(
64
+ sceneDescription?.components,
65
+ board,
66
+ refinedBoard
67
+ ),
68
+ externalPlacements:
69
+ PcbScene3dBoardOutlineRefiner.#realignLocalPlacements(
70
+ sceneDescription?.externalPlacements,
71
+ board,
72
+ refinedBoard
73
+ )
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Realigns precomputed local placements to a refined board center.
79
+ * @param {object[] | undefined} placements Scene placements.
80
+ * @param {{ centerX?: number, centerY?: number }} previousBoard Previous board.
81
+ * @param {{ centerX?: number, centerY?: number }} refinedBoard Refined board.
82
+ * @returns {object[] | undefined}
83
+ */
84
+ static #realignLocalPlacements(placements, previousBoard, refinedBoard) {
85
+ if (!Array.isArray(placements)) {
86
+ return placements
87
+ }
88
+
89
+ const deltaX =
90
+ Number(previousBoard?.centerX || 0) -
91
+ Number(refinedBoard?.centerX || 0)
92
+ const deltaY =
93
+ Number(previousBoard?.centerY || 0) -
94
+ Number(refinedBoard?.centerY || 0)
95
+
96
+ if (!deltaX && !deltaY) {
97
+ return placements
98
+ }
99
+
100
+ return placements.map((placement) =>
101
+ PcbScene3dBoardOutlineRefiner.#realignLocalPlacement(
102
+ placement,
103
+ deltaX,
104
+ deltaY
105
+ )
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Applies one local origin delta to a scene placement.
111
+ * @param {object} placement Scene placement.
112
+ * @param {number} deltaX Local X delta.
113
+ * @param {number} deltaY Local Y delta.
114
+ * @returns {object}
115
+ */
116
+ static #realignLocalPlacement(placement, deltaX, deltaY) {
117
+ const positionMil = placement?.positionMil || {}
118
+
119
+ return {
120
+ ...placement,
121
+ positionMil: {
122
+ ...positionMil,
123
+ x: Number(positionMil.x || 0) + deltaX,
124
+ y: Number(positionMil.y || 0) + deltaY
60
125
  }
61
126
  }
62
127
  }
@@ -19,6 +19,9 @@ export class PcbScene3dBuilder {
19
19
  static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
20
20
  static #DENSE_OVERLAY_KNOCKOUT_COLOR = 0x2f6a2c
21
21
  static #PRECISE_BODY_MATCH_TOLERANCE_MIL = 5
22
+ static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
23
+ static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
24
+ static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
22
25
  static #TRUETYPE_TEXT_WIDTH_RATIO = 0.55
23
26
 
24
27
  /**
@@ -1159,16 +1162,41 @@ export class PcbScene3dBuilder {
1159
1162
  static #isBodyPositionNearBoard(componentBody, board) {
1160
1163
  const bodyX = Number(componentBody?.positionMil?.x || 0)
1161
1164
  const bodyY = Number(componentBody?.positionMil?.y || 0)
1162
- const minX = Number(board?.minX || 0) - 600
1163
- const minY = Number(board?.minY || 0) - 600
1165
+ const xOverhang = PcbScene3dBuilder.#resolveUnmatchedBodyOverhang(
1166
+ board?.widthMil
1167
+ )
1168
+ const yOverhang = PcbScene3dBuilder.#resolveUnmatchedBodyOverhang(
1169
+ board?.heightMil
1170
+ )
1171
+ const minX = Number(board?.minX || 0) - xOverhang
1172
+ const minY = Number(board?.minY || 0) - yOverhang
1164
1173
  const maxX =
1165
- Number(board?.minX || 0) + Number(board?.widthMil || 0) + 600
1174
+ Number(board?.minX || 0) + Number(board?.widthMil || 0) + xOverhang
1166
1175
  const maxY =
1167
- Number(board?.minY || 0) + Number(board?.heightMil || 0) + 600
1176
+ Number(board?.minY || 0) + Number(board?.heightMil || 0) + yOverhang
1168
1177
 
1169
1178
  return bodyX >= minX && bodyX <= maxX && bodyY >= minY && bodyY <= maxY
1170
1179
  }
1171
1180
 
1181
+ /**
1182
+ * Resolves a proportional unresolved-body margin for one board axis.
1183
+ * @param {number | string | undefined} spanMil Board axis span.
1184
+ * @returns {number}
1185
+ */
1186
+ static #resolveUnmatchedBodyOverhang(spanMil) {
1187
+ const proportional =
1188
+ Math.max(Number(spanMil || 0), 0) *
1189
+ PcbScene3dBuilder.#UNMATCHED_BODY_OVERHANG_RATIO
1190
+
1191
+ return Math.min(
1192
+ PcbScene3dBuilder.#UNMATCHED_BODY_MAX_OVERHANG_MIL,
1193
+ Math.max(
1194
+ proportional,
1195
+ PcbScene3dBuilder.#UNMATCHED_BODY_MIN_OVERHANG_MIL
1196
+ )
1197
+ )
1198
+ }
1199
+
1172
1200
  /**
1173
1201
  * Normalizes one angle into the range [0, 360).
1174
1202
  * @param {number} angle
@@ -341,7 +341,7 @@ export class PcbSvgRenderer {
341
341
  '"><path d="' +
342
342
  SchematicSvgUtils.escapeHtml(path) +
343
343
  '" /></clipPath></defs>' +
344
- '<path class="board-outline" d="' +
344
+ '<path class="board-outline pcb-layer pcb-layer--edge-cuts" data-layer-name="Edge.Cuts" d="' +
345
345
  SchematicSvgUtils.escapeHtml(path) +
346
346
  '" />' +
347
347
  '<g class="pcb-copper-layers" clip-path="url(#' +
@@ -380,7 +380,7 @@ export class PcbSvgRenderer {
380
380
  '>' +
381
381
  textMarkup +
382
382
  '</g>' +
383
- '<path class="board-outline board-outline--stroke" d="' +
383
+ '<path class="board-outline board-outline--stroke pcb-layer pcb-layer--edge-cuts" data-layer-name="Edge.Cuts" d="' +
384
384
  SchematicSvgUtils.escapeHtml(path) +
385
385
  '" />' +
386
386
  '</svg></div></section>'
@@ -132,7 +132,7 @@ export class PcbTextPrimitiveRenderer {
132
132
  metrics.lineHeight
133
133
  )
134
134
  const rectX = -layout.anchorX
135
- const rectY = layout.anchorY - layout.height
135
+ const rectY = -layout.anchorY
136
136
  const cornerRadius = Math.min(
137
137
  padding,
138
138
  layout.width / 2,
@@ -360,7 +360,21 @@ export class PcbTextPrimitiveRenderer {
360
360
  return null
361
361
  }
362
362
 
363
- context.font = PcbTextPrimitiveRenderer.#buildCanvasFont(text, fontSize)
363
+ const canvasFont = PcbTextPrimitiveRenderer.#buildCanvasFont(
364
+ text,
365
+ fontSize
366
+ )
367
+ if (
368
+ !PcbTextPrimitiveRenderer.#canMeasureCanvasFont(
369
+ text,
370
+ canvasFont,
371
+ fontSize
372
+ )
373
+ ) {
374
+ return null
375
+ }
376
+
377
+ context.font = canvasFont
364
378
 
365
379
  const measured = lines.map((line) => context.measureText(line || ' '))
366
380
  const ascent = PcbTextPrimitiveRenderer.#resolveMeasuredExtent(
@@ -388,6 +402,29 @@ export class PcbTextPrimitiveRenderer {
388
402
  }
389
403
  }
390
404
 
405
+ /**
406
+ * Checks whether canvas text metrics can be trusted for the requested
407
+ * imported font.
408
+ * @param {{ fontMetrics?: { averageAdvanceWidth?: number, unitsPerEm?: number } }} text Text record.
409
+ * @param {string} canvasFont Full canvas font shorthand.
410
+ * @param {number} fontSize Text font size.
411
+ * @returns {boolean}
412
+ */
413
+ static #canMeasureCanvasFont(text, canvasFont, fontSize) {
414
+ const fonts = globalThis.document?.fonts
415
+ if (typeof fonts?.check === 'function') {
416
+ return fonts.check(
417
+ PcbTextPrimitiveRenderer.#buildPrimaryCanvasFont(text, fontSize)
418
+ )
419
+ }
420
+
421
+ if (PcbTextPrimitiveRenderer.#fontMetricsAverageWidthRatio(text)) {
422
+ return false
423
+ }
424
+
425
+ return Boolean(canvasFont)
426
+ }
427
+
391
428
  /**
392
429
  * Resolves a measured text extent with a stable fallback.
393
430
  * @param {TextMetrics[]} measured Browser text metrics.
@@ -420,6 +457,25 @@ export class PcbTextPrimitiveRenderer {
420
457
  return `${style} ${weight} ${fontSize}px ${family}`
421
458
  }
422
459
 
460
+ /**
461
+ * Builds a single-family font shorthand for readiness checks.
462
+ * @param {{ fontFamily?: string, fontName?: string, isBold?: boolean, fontWeight?: number, isItalic?: boolean }} text Text record.
463
+ * @param {number} fontSize Text font size.
464
+ * @returns {string}
465
+ */
466
+ static #buildPrimaryCanvasFont(text, fontSize) {
467
+ const weight =
468
+ text?.isBold || Number(text?.fontWeight) >= 600 ? '700' : '400'
469
+ const style = text?.isItalic ? 'italic' : 'normal'
470
+ const family = PcbTextPrimitiveRenderer.#quoteFontFamily(
471
+ PcbTextPrimitiveRenderer.#cleanFontFamily(
472
+ text?.fontFamily || text?.fontName
473
+ )
474
+ )
475
+
476
+ return `${style} ${weight} ${fontSize}px ${family}`
477
+ }
478
+
423
479
  /**
424
480
  * Builds a browser font-family stack matching the 3D TrueType path.
425
481
  * @param {unknown} family Font family value.
@@ -195,7 +195,7 @@ export class SchematicContentLayout {
195
195
  ' ' +
196
196
  formatNumber(targetMinY) +
197
197
  ') scale(' +
198
- formatNumber(scale) +
198
+ SchematicContentLayout.#formatScale(scale) +
199
199
  ') translate(' +
200
200
  formatNumber(-bounds.minX) +
201
201
  ' ' +
@@ -252,20 +252,28 @@ export class SchematicContentLayout {
252
252
  return ''
253
253
  }
254
254
 
255
- const pivotX = margin
256
- const pivotY = height - margin
257
255
  const topLimit = margin + contentPadding * 0.2
258
256
  const bottomLimit = height - margin - footerReserve
259
257
  const rightLimit = width - margin
258
+ const usedWidth = bounds.maxX - bounds.minX
259
+ const usedHeight = bounds.maxY - bounds.minY
260
+
261
+ if (usedWidth <= 0 || usedHeight <= 0) {
262
+ return ''
263
+ }
264
+
260
265
  const scale = Math.min(
261
266
  targetScale,
262
- ...SchematicContentLayout.#resolvePivotScaleLimits(
263
- bounds,
264
- pivotX,
265
- pivotY,
267
+ SchematicContentLayout.#resolveContentScaleLimit(
268
+ bounds.minX,
269
+ bounds.maxX,
266
270
  margin,
271
+ rightLimit
272
+ ),
273
+ SchematicContentLayout.#resolveContentScaleLimit(
274
+ bounds.minY,
275
+ bounds.maxY,
267
276
  topLimit,
268
- rightLimit,
269
277
  bottomLimit
270
278
  )
271
279
  )
@@ -274,10 +282,18 @@ export class SchematicContentLayout {
274
282
  return ''
275
283
  }
276
284
 
277
- const projectedMinX = pivotX + (bounds.minX - pivotX) * scale
278
- const projectedMaxX = pivotX + (bounds.maxX - pivotX) * scale
279
- const projectedMinY = pivotY + (bounds.minY - pivotY) * scale
280
- const projectedMaxY = pivotY + (bounds.maxY - pivotY) * scale
285
+ const targetMinX =
286
+ SchematicContentLayout.#resolveCenteredContentTargetMinX(
287
+ bounds,
288
+ scale,
289
+ margin,
290
+ rightLimit
291
+ )
292
+ const projectedMinX = targetMinX
293
+ const projectedMaxX = targetMinX + usedWidth * scale
294
+ const targetMinY = bottomLimit - usedHeight * scale
295
+ const projectedMinY = targetMinY
296
+ const projectedMaxY = bottomLimit
281
297
 
282
298
  if (
283
299
  projectedMinX < margin - 0.01 ||
@@ -290,19 +306,73 @@ export class SchematicContentLayout {
290
306
 
291
307
  return (
292
308
  ' transform="translate(' +
293
- formatNumber(pivotX) +
309
+ formatNumber(targetMinX) +
294
310
  ' ' +
295
- formatNumber(pivotY) +
311
+ formatNumber(targetMinY) +
296
312
  ') scale(' +
297
- formatNumber(scale) +
313
+ SchematicContentLayout.#formatScale(scale) +
298
314
  ') translate(' +
299
- formatNumber(-pivotX) +
315
+ formatNumber(-bounds.minX) +
300
316
  ' ' +
301
- formatNumber(-pivotY) +
317
+ formatNumber(-bounds.minY) +
302
318
  ')"'
303
319
  )
304
320
  }
305
321
 
322
+ /**
323
+ * Resolves one scale cap that keeps a horizontally centered content
324
+ * envelope inside the sheet frame.
325
+ * @param {number} minCoordinate
326
+ * @param {number} maxCoordinate
327
+ * @param {number} minLimit
328
+ * @param {number} maxLimit
329
+ * @returns {number}
330
+ */
331
+ static #resolveContentScaleLimit(
332
+ minCoordinate,
333
+ maxCoordinate,
334
+ minLimit,
335
+ maxLimit
336
+ ) {
337
+ const usedWidth = maxCoordinate - minCoordinate
338
+ const availableWidth = maxLimit - minLimit
339
+
340
+ return usedWidth > 0 && availableWidth > 0
341
+ ? availableWidth / usedWidth
342
+ : Infinity
343
+ }
344
+
345
+ /**
346
+ * Formats SVG transform scales with enough precision to avoid visible
347
+ * placement drift on large schematic sheets.
348
+ * @param {number} value
349
+ * @returns {string}
350
+ */
351
+ static #formatScale(value) {
352
+ return Number(value).toFixed(4).replace(/0+$/, '').replace(/\.$/, '')
353
+ }
354
+
355
+ /**
356
+ * Resolves the target SVG x-coordinate for a scaled content envelope.
357
+ * @param {{ minX: number, maxX: number }} bounds
358
+ * @param {number} scale
359
+ * @param {number} leftLimit
360
+ * @param {number} rightLimit
361
+ * @returns {number}
362
+ */
363
+ static #resolveCenteredContentTargetMinX(
364
+ bounds,
365
+ scale,
366
+ leftLimit,
367
+ rightLimit
368
+ ) {
369
+ const scaledWidth = (bounds.maxX - bounds.minX) * scale
370
+ const availableWidth = rightLimit - leftLimit
371
+ const remainingWidth = Math.max(availableWidth - scaledWidth, 0)
372
+
373
+ return leftLimit + remainingWidth / 2
374
+ }
375
+
306
376
  /**
307
377
  * Resolves per-edge maximum scale factors for a bottom-left pivot.
308
378
  * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
@@ -436,11 +506,43 @@ export class SchematicContentLayout {
436
506
  const heightSlackRatio =
437
507
  (matchingSheet.height - requiredHeight) / requiredHeight
438
508
 
439
- return widthSlackRatio <= RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO &&
440
- heightSlackRatio <= RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO &&
441
- matchingSheet.width < width
442
- ? matchingSheet
443
- : null
509
+ if (
510
+ widthSlackRatio > RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO ||
511
+ heightSlackRatio > RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO ||
512
+ matchingSheet.width >= width
513
+ ) {
514
+ return null
515
+ }
516
+
517
+ return {
518
+ ...matchingSheet,
519
+ width: SchematicContentLayout.#resolveTightVirtualDimension(
520
+ requiredWidth,
521
+ matchingSheet.width,
522
+ margin
523
+ )
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Drops near-zero standard-page rounding slack from the scale axis so
529
+ * custom sheets that almost fill a standard source size do not render with
530
+ * a visible artificial gutter.
531
+ * @param {number} requiredDimension
532
+ * @param {number} candidateDimension
533
+ * @param {number} margin
534
+ * @returns {number}
535
+ */
536
+ static #resolveTightVirtualDimension(
537
+ requiredDimension,
538
+ candidateDimension,
539
+ margin
540
+ ) {
541
+ const slack = candidateDimension - requiredDimension
542
+
543
+ return slack > 0 && slack <= margin
544
+ ? requiredDimension
545
+ : candidateDimension
444
546
  }
445
547
 
446
548
  /**
@@ -18,6 +18,7 @@ export class SchematicJunctionRenderer {
18
18
  * @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} [ports]
19
19
  * @param {{ x: number, y: number, style?: number, powerPortDirection?: 'up' | 'down' | 'left' | 'right' }[]} [powerPorts]
20
20
  * @param {number} sheetHeight
21
+ * @param {{ x: number, y: number }[]} [authoredJunctions]
21
22
  * @returns {string}
22
23
  */
23
24
  static buildMarkup(
@@ -25,13 +26,15 @@ export class SchematicJunctionRenderer {
25
26
  crosses,
26
27
  ports = [],
27
28
  powerPorts = [],
28
- sheetHeight
29
+ sheetHeight,
30
+ authoredJunctions = []
29
31
  ) {
30
32
  return SchematicJunctionRenderer.#resolveJunctions(
31
33
  lines,
32
34
  crosses,
33
35
  ports,
34
- powerPorts
36
+ powerPorts,
37
+ authoredJunctions
35
38
  )
36
39
  .map(
37
40
  (junction) =>
@@ -57,9 +60,16 @@ export class SchematicJunctionRenderer {
57
60
  * @param {{ x: number, y: number }[]} crosses
58
61
  * @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
59
62
  * @param {{ x: number, y: number, style?: number, powerPortDirection?: 'up' | 'down' | 'left' | 'right' }[]} powerPorts
63
+ * @param {{ x: number, y: number }[]} authoredJunctions
60
64
  * @returns {{ x: number, y: number, color: string }[]}
61
65
  */
62
- static #resolveJunctions(lines, crosses, ports, powerPorts) {
66
+ static #resolveJunctions(
67
+ lines,
68
+ crosses,
69
+ ports,
70
+ powerPorts,
71
+ authoredJunctions
72
+ ) {
63
73
  const wireLines = lines.filter((line) =>
64
74
  SchematicJunctionRenderer.#isElectricalWireLine(line)
65
75
  )
@@ -69,6 +79,11 @@ export class SchematicJunctionRenderer {
69
79
  const visiblePowerPorts = powerPorts.filter((powerPort) =>
70
80
  SchematicJunctionRenderer.#isDrawablePowerPort(powerPort)
71
81
  )
82
+ const authoredJunctionKeys = new Set(
83
+ authoredJunctions.map((junction) =>
84
+ SchematicJunctionRenderer.#pointKey(junction)
85
+ )
86
+ )
72
87
 
73
88
  return SchematicJunctionRenderer.#collectCandidatePoints(
74
89
  wireLines,
@@ -77,6 +92,9 @@ export class SchematicJunctionRenderer {
77
92
  )
78
93
  .filter(
79
94
  (point) =>
95
+ !authoredJunctionKeys.has(
96
+ SchematicJunctionRenderer.#pointKey(point)
97
+ ) &&
80
98
  !SchematicJunctionRenderer.#hasNearbyCross(point, crosses)
81
99
  )
82
100
  .flatMap((point) => {