altium-toolkit 0.1.20 → 0.1.22

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,272 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Resolves 3D component-body identity and mount-side hints.
7
+ */
8
+ export class PcbScene3dPlacementSideResolver {
9
+ static #NEARBY_SIDE_HINT_MAX_DISTANCE_MIL = 600
10
+ static #NEGATIVE_STANDOFF_SIDE_RATIO = 0.3
11
+ static #MIN_NEGATIVE_STANDOFF_SIDE_MIL = 20
12
+
13
+ /**
14
+ * Resolves which board side one explicit model should mount on.
15
+ * @param {{ layer?: string, standoffHeightMil?: number | null, overallHeightMil?: number | null }} componentBody
16
+ * @param {{ layer?: string } | null} matchedComponent
17
+ * @param {{ layer?: string, pattern?: string, source?: string, modelPath?: string, x?: number, y?: number }[]} components
18
+ * @returns {'top' | 'bottom'}
19
+ */
20
+ static resolvePlacementSide(
21
+ componentBody,
22
+ matchedComponent,
23
+ components = []
24
+ ) {
25
+ const matchedSide =
26
+ PcbScene3dPlacementSideResolver.#resolveComponentLayerSide(
27
+ matchedComponent?.layer
28
+ )
29
+ if (matchedSide) {
30
+ return matchedSide
31
+ }
32
+
33
+ const standoffSide =
34
+ PcbScene3dPlacementSideResolver.#resolveStandoffSide(componentBody)
35
+ if (standoffSide) {
36
+ return standoffSide
37
+ }
38
+
39
+ const nearbySide =
40
+ PcbScene3dPlacementSideResolver.#resolveNearbyComponentSide(
41
+ componentBody,
42
+ components
43
+ )
44
+ if (nearbySide) {
45
+ return nearbySide
46
+ }
47
+
48
+ return (
49
+ PcbScene3dPlacementSideResolver.#resolveMechanicalLayerSide(
50
+ componentBody?.layer
51
+ ) || 'top'
52
+ )
53
+ }
54
+
55
+ /**
56
+ * Resolves the grouping key for repeated component-body matching.
57
+ * @param {{ modelId?: string, name?: string, identifier?: string }} componentBody
58
+ * @returns {string}
59
+ */
60
+ static resolveBodyGroupKey(componentBody) {
61
+ return PcbScene3dPlacementSideResolver.#normalizeLookupToken(
62
+ componentBody?.modelId ||
63
+ componentBody?.name ||
64
+ componentBody?.identifier
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Scores how strongly one component record appears to belong to one body
70
+ * record based on shared model/footprint tokens.
71
+ * @param {{ name?: string, identifier?: string }} componentBody
72
+ * @param {{ pattern?: string, source?: string, modelPath?: string }} component
73
+ * @returns {number}
74
+ */
75
+ static scoreBodyComponentAffinity(componentBody, component) {
76
+ const bodyTokens =
77
+ PcbScene3dPlacementSideResolver.#collectMeaningfulTokens([
78
+ componentBody?.identifier,
79
+ String(componentBody?.name || '').replace(/\.[^.]+$/, '')
80
+ ])
81
+ const componentTokens =
82
+ PcbScene3dPlacementSideResolver.#collectMeaningfulTokens([
83
+ component?.pattern,
84
+ component?.source,
85
+ component?.modelPath
86
+ ])
87
+ let score = 0
88
+
89
+ bodyTokens.forEach((token) => {
90
+ if (componentTokens.has(token)) {
91
+ score += token.length
92
+ }
93
+ })
94
+
95
+ return score
96
+ }
97
+
98
+ /**
99
+ * Resolves side from the normalized component layer.
100
+ * @param {string | undefined} layer
101
+ * @returns {'top' | 'bottom' | null}
102
+ */
103
+ static #resolveComponentLayerSide(layer) {
104
+ const normalized = String(layer || '')
105
+ .trim()
106
+ .toUpperCase()
107
+
108
+ if (!normalized) {
109
+ return null
110
+ }
111
+
112
+ if (normalized.includes('BOTTOM') || normalized === 'BOT') {
113
+ return 'bottom'
114
+ }
115
+
116
+ if (normalized.includes('TOP')) {
117
+ return 'top'
118
+ }
119
+
120
+ return null
121
+ }
122
+
123
+ /**
124
+ * Resolves underside bodies from significant negative standoff metadata.
125
+ * @param {{ standoffHeightMil?: number | null, overallHeightMil?: number | null } | null} componentBody
126
+ * @returns {'bottom' | null}
127
+ */
128
+ static #resolveStandoffSide(componentBody) {
129
+ const standoff = Number(componentBody?.standoffHeightMil)
130
+ const overallHeight = Number(componentBody?.overallHeightMil)
131
+
132
+ if (!Number.isFinite(standoff) || standoff >= 0) {
133
+ return null
134
+ }
135
+
136
+ const threshold =
137
+ Number.isFinite(overallHeight) && overallHeight > 0
138
+ ? Math.max(
139
+ overallHeight *
140
+ PcbScene3dPlacementSideResolver
141
+ .#NEGATIVE_STANDOFF_SIDE_RATIO,
142
+ PcbScene3dPlacementSideResolver
143
+ .#MIN_NEGATIVE_STANDOFF_SIDE_MIL
144
+ )
145
+ : PcbScene3dPlacementSideResolver
146
+ .#MIN_NEGATIVE_STANDOFF_SIDE_MIL
147
+
148
+ return Math.abs(standoff) >= threshold ? 'bottom' : null
149
+ }
150
+
151
+ /**
152
+ * Resolves side from the nearest footprint-compatible component.
153
+ * @param {{ positionMil?: { x?: number, y?: number } } & { name?: string, identifier?: string }} componentBody
154
+ * @param {{ layer?: string, pattern?: string, source?: string, modelPath?: string, x?: number, y?: number }[]} components
155
+ * @returns {'top' | 'bottom' | null}
156
+ */
157
+ static #resolveNearbyComponentSide(componentBody, components) {
158
+ const candidates = (Array.isArray(components) ? components : [])
159
+ .map((component) => ({
160
+ component,
161
+ side: PcbScene3dPlacementSideResolver.#resolveComponentLayerSide(
162
+ component?.layer
163
+ ),
164
+ score: PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
165
+ componentBody,
166
+ component
167
+ ),
168
+ distance:
169
+ PcbScene3dPlacementSideResolver.#distanceBetweenBodyAndComponent(
170
+ componentBody,
171
+ component
172
+ )
173
+ }))
174
+ .filter(
175
+ (candidate) =>
176
+ candidate.side &&
177
+ candidate.score > 0 &&
178
+ candidate.distance <=
179
+ PcbScene3dPlacementSideResolver
180
+ .#NEARBY_SIDE_HINT_MAX_DISTANCE_MIL
181
+ )
182
+ .sort(
183
+ (left, right) =>
184
+ right.score - left.score || left.distance - right.distance
185
+ )
186
+
187
+ return candidates[0]?.side || null
188
+ }
189
+
190
+ /**
191
+ * Resolves a common Altium top/bottom mechanical layer pair.
192
+ * @param {string | undefined} layer
193
+ * @returns {'top' | 'bottom' | null}
194
+ */
195
+ static #resolveMechanicalLayerSide(layer) {
196
+ const match = String(layer || '').match(/^MECHANICAL\s*(\d+)$/i)
197
+ if (!match) {
198
+ return null
199
+ }
200
+
201
+ return Number(match[1]) % 2 === 0 ? 'bottom' : 'top'
202
+ }
203
+
204
+ /**
205
+ * Returns the euclidean distance between one body anchor and one component
206
+ * anchor.
207
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody
208
+ * @param {{ x?: number, y?: number }} component
209
+ * @returns {number}
210
+ */
211
+ static #distanceBetweenBodyAndComponent(componentBody, component) {
212
+ return Math.hypot(
213
+ Number(component?.x || 0) -
214
+ Number(componentBody?.positionMil?.x || 0),
215
+ Number(component?.y || 0) -
216
+ Number(componentBody?.positionMil?.y || 0)
217
+ )
218
+ }
219
+
220
+ /**
221
+ * Collects normalized model tokens from free-form strings.
222
+ * @param {(string | undefined)[]} values
223
+ * @returns {Set<string>}
224
+ */
225
+ static #collectMeaningfulTokens(values) {
226
+ const tokens = new Set()
227
+
228
+ ;(Array.isArray(values) ? values : []).forEach((value) => {
229
+ String(value || '')
230
+ .toLowerCase()
231
+ .split(/[^a-z0-9]+/g)
232
+ .forEach((fragment) => {
233
+ ;(fragment.match(/[a-z]+|\d+/g) || []).forEach((token) => {
234
+ if (
235
+ PcbScene3dPlacementSideResolver.#isMeaningfulToken(
236
+ token
237
+ )
238
+ ) {
239
+ tokens.add(token)
240
+ }
241
+ })
242
+ })
243
+ })
244
+
245
+ return tokens
246
+ }
247
+
248
+ /**
249
+ * Returns true when one normalized token carries useful model identity.
250
+ * @param {string} token
251
+ * @returns {boolean}
252
+ */
253
+ static #isMeaningfulToken(token) {
254
+ return (
255
+ String(token || '').length >= 2 &&
256
+ !new Set(['con', 'step', 'stp', 'model', 'default', 'black']).has(
257
+ String(token || '')
258
+ )
259
+ )
260
+ }
261
+
262
+ /**
263
+ * Normalizes one lookup token for repeated-model grouping.
264
+ * @param {string | undefined} value
265
+ * @returns {string}
266
+ */
267
+ static #normalizeLookupToken(value) {
268
+ return String(value || '')
269
+ .toLowerCase()
270
+ .replace(/[^a-z0-9]+/g, '')
271
+ }
272
+ }
@@ -0,0 +1,95 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Resolves Altium TrueType text-box metadata for 3D scene consumers.
7
+ */
8
+ export class PcbScene3dTextBoxLayoutResolver {
9
+ static #COMPACT_IMPLICIT_WIDTH_RATIO = 2.25
10
+
11
+ /**
12
+ * Resolves one Altium inverted text box into renderable dimensions.
13
+ * @param {{ isInverted?: boolean, useInvertedRectangle?: boolean, textboxRectWidth?: number, textboxRectHeight?: number, textboxRectJustification?: number, marginBorderWidth?: number, height?: number }} text
14
+ * @returns {{ source: string, mode: 'explicit' | 'implicit', compact: boolean, widthMil: number, heightMil: number, marginMil: number, renderWidthMil: number, renderHeightMil: number, justification: { column: number, row: number } | null } | null}
15
+ */
16
+ static resolve(text) {
17
+ const width = Number(text?.textboxRectWidth)
18
+ const height = Number(text?.textboxRectHeight)
19
+
20
+ if (
21
+ !Boolean(text?.isInverted) ||
22
+ !Number.isFinite(width) ||
23
+ !Number.isFinite(height) ||
24
+ width <= 0 ||
25
+ height <= 0
26
+ ) {
27
+ return null
28
+ }
29
+
30
+ const mode = Boolean(text?.useInvertedRectangle)
31
+ ? 'explicit'
32
+ : 'implicit'
33
+ const margin = PcbScene3dTextBoxLayoutResolver.#margin(text)
34
+ const compact =
35
+ mode === 'implicit' &&
36
+ width <=
37
+ PcbScene3dTextBoxLayoutResolver.#height(text) *
38
+ PcbScene3dTextBoxLayoutResolver
39
+ .#COMPACT_IMPLICIT_WIDTH_RATIO
40
+ const border = compact ? margin * 2 : 0
41
+
42
+ return {
43
+ source: 'altium-textbox',
44
+ mode,
45
+ compact,
46
+ widthMil: width,
47
+ heightMil: height,
48
+ marginMil: margin,
49
+ renderWidthMil: width + border,
50
+ renderHeightMil: height + border,
51
+ justification: PcbScene3dTextBoxLayoutResolver.#justification(text)
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Resolves one non-negative margin value.
57
+ * @param {{ marginBorderWidth?: number }} text
58
+ * @returns {number}
59
+ */
60
+ static #margin(text) {
61
+ const margin = Number(text?.marginBorderWidth)
62
+
63
+ return Number.isFinite(margin) && margin >= 0 ? margin : 0
64
+ }
65
+
66
+ /**
67
+ * Resolves the authored text height used for compact box detection.
68
+ * @param {{ height?: number }} text
69
+ * @returns {number}
70
+ */
71
+ static #height(text) {
72
+ return Math.max(Number(text?.height || 0), 1)
73
+ }
74
+
75
+ /**
76
+ * Decodes Altium's three-by-three text-box justification code.
77
+ * @param {{ textboxRectJustification?: number }} text
78
+ * @returns {{ column: number, row: number } | null}
79
+ */
80
+ static #justification(text) {
81
+ const justification = Number(text?.textboxRectJustification)
82
+
83
+ if (!Number.isInteger(justification) || justification <= 0) {
84
+ return null
85
+ }
86
+
87
+ return {
88
+ column: Math.max(
89
+ 0,
90
+ Math.min(2, Math.floor((justification - 1) / 3))
91
+ ),
92
+ row: (justification - 1) % 3
93
+ }
94
+ }
95
+ }
@@ -6,6 +6,7 @@ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
6
  import { SchematicColorResolver } from './SchematicColorResolver.mjs'
7
7
 
8
8
  const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
9
+ const MISSING_IMAGE_WRAP_SAFETY_FACTOR = 0.96
9
10
 
10
11
  /**
11
12
  * Renders normalized schematic image placements.
@@ -13,7 +14,7 @@ const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
13
14
  export class SchematicImageRenderer {
14
15
  /**
15
16
  * Builds markup for embedded schematic images and unresolved placeholders.
16
- * @param {{ x: number, y: number, cornerX: number, cornerY: number, mimeType?: string, dataBase64?: string, diagnosticState?: string, keepAspect?: boolean }[]} images
17
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string, mimeType?: string, dataBase64?: string, diagnosticState?: string, keepAspect?: boolean }[]} images
17
18
  * @param {number} sheetHeight
18
19
  * @returns {string}
19
20
  */
@@ -63,20 +64,16 @@ export class SchematicImageRenderer {
63
64
 
64
65
  /**
65
66
  * Builds one placeholder frame when an image payload is unavailable.
66
- * @param {{ x: number, y: number, cornerX: number, cornerY: number }} image
67
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string }} image
67
68
  * @param {number} sheetHeight
68
69
  * @returns {string}
69
70
  */
70
71
  static #buildPlaceholderMarkup(image, sheetHeight) {
71
72
  const bounds = SchematicImageRenderer.#resolveBounds(image, sheetHeight)
72
- const stroke = SchematicColorResolver.resolveColor(
73
- '#c0c0c0',
74
- '--schematic-note-border-color'
75
- )
76
73
 
77
74
  return (
78
75
  '<g class="schematic-image-placeholder">' +
79
- '<rect x="' +
76
+ '<svg x="' +
80
77
  formatNumber(bounds.x) +
81
78
  '" y="' +
82
79
  formatNumber(bounds.y) +
@@ -84,35 +81,140 @@ export class SchematicImageRenderer {
84
81
  formatNumber(bounds.width) +
85
82
  '" height="' +
86
83
  formatNumber(bounds.height) +
87
- '" fill="none" stroke="' +
88
- escapeHtml(stroke) +
89
- '" stroke-width="1" />' +
90
- '<line x1="' +
91
- formatNumber(bounds.x) +
92
- '" y1="' +
93
- formatNumber(bounds.y) +
94
- '" x2="' +
95
- formatNumber(bounds.x + bounds.width) +
96
- '" y2="' +
97
- formatNumber(bounds.y + bounds.height) +
98
- '" stroke="' +
99
- escapeHtml(stroke) +
100
- '" stroke-width="1" />' +
101
- '<line x1="' +
102
- formatNumber(bounds.x) +
103
- '" y1="' +
104
- formatNumber(bounds.y + bounds.height) +
105
- '" x2="' +
106
- formatNumber(bounds.x + bounds.width) +
107
- '" y2="' +
108
- formatNumber(bounds.y) +
109
- '" stroke="' +
110
- escapeHtml(stroke) +
111
- '" stroke-width="1" />' +
84
+ '" overflow="hidden">' +
85
+ SchematicImageRenderer.#buildMissingImageMessageMarkup(
86
+ image,
87
+ bounds
88
+ ) +
89
+ '</svg>' +
112
90
  '</g>'
113
91
  )
114
92
  }
115
93
 
94
+ /**
95
+ * Builds the visible message shown by Altium for unavailable image files.
96
+ * @param {{ fileName?: string }} image
97
+ * @param {{ x: number, y: number, width: number, height: number }} bounds
98
+ * @returns {string}
99
+ */
100
+ static #buildMissingImageMessageMarkup(image, bounds) {
101
+ const padding = Math.min(6, Math.max(bounds.width * 0.08, 2))
102
+ const fontSize = Math.min(8, Math.max(bounds.height / 18, 5))
103
+ const lineHeight = fontSize * 1.18
104
+ const usableWidth = Math.max(bounds.width - padding * 2, 1)
105
+ const lines = SchematicImageRenderer.#buildMissingImageMessageLines(
106
+ image.fileName,
107
+ usableWidth * MISSING_IMAGE_WRAP_SAFETY_FACTOR,
108
+ fontSize
109
+ )
110
+ const textColor = SchematicColorResolver.resolveColor(
111
+ '#2c3134',
112
+ '--schematic-text-color'
113
+ )
114
+ const startX = padding
115
+ const startY = padding + fontSize
116
+
117
+ return (
118
+ '<text class="schematic-image-placeholder-message" x="' +
119
+ formatNumber(startX) +
120
+ '" y="' +
121
+ formatNumber(startY) +
122
+ '" fill="' +
123
+ escapeHtml(textColor) +
124
+ '" font-family="Times New Roman" font-size="' +
125
+ formatNumber(fontSize) +
126
+ '">' +
127
+ lines
128
+ .map(
129
+ (line, index) =>
130
+ '<tspan x="' +
131
+ formatNumber(startX) +
132
+ '" dy="' +
133
+ formatNumber(index === 0 ? 0 : lineHeight) +
134
+ '">' +
135
+ escapeHtml(line) +
136
+ '</tspan>'
137
+ )
138
+ .join('') +
139
+ '</text>'
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Wraps one missing-image message to the image placeholder width.
145
+ * @param {string | undefined} fileName
146
+ * @param {number} width
147
+ * @param {number} fontSize
148
+ * @returns {string[]}
149
+ */
150
+ static #buildMissingImageMessageLines(fileName, width, fontSize) {
151
+ return [
152
+ 'Cannot open file',
153
+ ...SchematicImageRenderer.#wrapMissingImageFileName(
154
+ fileName || 'image file',
155
+ width,
156
+ fontSize
157
+ ),
158
+ '. File does not exist.'
159
+ ]
160
+ }
161
+
162
+ /**
163
+ * Wraps file names using conservative estimated rendered glyph widths.
164
+ * @param {string} fileName
165
+ * @param {number} width
166
+ * @param {number} fontSize
167
+ * @returns {string[]}
168
+ */
169
+ static #wrapMissingImageFileName(fileName, width, fontSize) {
170
+ const maxWidth = Math.max(Number(width || 0), 1)
171
+ const value = String(fileName || '').trim()
172
+ const lines = []
173
+ let line = ''
174
+ let lineWidth = 0
175
+
176
+ for (const character of value) {
177
+ const characterWidth =
178
+ SchematicImageRenderer.#estimateMissingImageCharacterWidth(
179
+ character,
180
+ fontSize
181
+ )
182
+
183
+ if (line && lineWidth + characterWidth > maxWidth) {
184
+ lines.push(line)
185
+ line = character
186
+ lineWidth = characterWidth
187
+ continue
188
+ }
189
+
190
+ line += character
191
+ lineWidth += characterWidth
192
+ }
193
+
194
+ if (line) {
195
+ lines.push(line)
196
+ }
197
+
198
+ return lines.length ? lines : ['image file']
199
+ }
200
+
201
+ /**
202
+ * Estimates one placeholder glyph width for Times-like schematic text.
203
+ * @param {string} character
204
+ * @param {number} fontSize
205
+ * @returns {number}
206
+ */
207
+ static #estimateMissingImageCharacterWidth(character, fontSize) {
208
+ if (/[^\x00-\x7F]/u.test(character)) return fontSize
209
+ if (/[A-Z]/.test(character)) return fontSize * 0.62
210
+ if (/[a-z]/.test(character)) return fontSize * 0.45
211
+ if (/[0-9]/.test(character)) return fontSize * 0.5
212
+ if (/[\\/]/.test(character)) return fontSize * 0.32
213
+ if (/[.:\-_]/.test(character)) return fontSize * 0.28
214
+
215
+ return fontSize * 0.35
216
+ }
217
+
116
218
  /**
117
219
  * Resolves one image placement into SVG-space bounds.
118
220
  * @param {{ x: number, y: number, cornerX: number, cornerY: number }} image
@@ -13,7 +13,7 @@ const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
13
13
  export class SchematicJunctionRenderer {
14
14
  /**
15
15
  * Builds junction-dot markup from connected wire linework.
16
- * @param {{ x1: number, y1: number, x2: number, y2: number, color: string, ownerIndex?: string, isBus?: boolean }[]} lines
16
+ * @param {{ x1: number, y1: number, x2: number, y2: number, color: string, ownerIndex?: string, isBus?: boolean, recordType?: string }[]} lines
17
17
  * @param {{ x: number, y: number }[]} crosses
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]
@@ -53,15 +53,15 @@ export class SchematicJunctionRenderer {
53
53
 
54
54
  /**
55
55
  * Resolves all wire-junction points that should display a connection dot.
56
- * @param {{ x1: number, y1: number, x2: number, y2: number, color: string, ownerIndex?: string, isBus?: boolean }[]} lines
56
+ * @param {{ x1: number, y1: number, x2: number, y2: number, color: string, ownerIndex?: string, isBus?: boolean, recordType?: string }[]} lines
57
57
  * @param {{ x: number, y: number }[]} crosses
58
58
  * @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
59
59
  * @param {{ x: number, y: number, style?: number, powerPortDirection?: 'up' | 'down' | 'left' | 'right' }[]} powerPorts
60
60
  * @returns {{ x: number, y: number, color: string }[]}
61
61
  */
62
62
  static #resolveJunctions(lines, crosses, ports, powerPorts) {
63
- const wireLines = lines.filter(
64
- (line) => !line.ownerIndex && line.isBus !== true
63
+ const wireLines = lines.filter((line) =>
64
+ SchematicJunctionRenderer.#isElectricalWireLine(line)
65
65
  )
66
66
  const verticalPorts = ports.filter((port) =>
67
67
  SchematicJunctionRenderer.#isVerticalPort(port)
@@ -370,6 +370,24 @@ export class SchematicJunctionRenderer {
370
370
  )
371
371
  }
372
372
 
373
+ /**
374
+ * Returns true when one line can participate in synthetic electrical
375
+ * junction dots.
376
+ * @param {{ ownerIndex?: string, isBus?: boolean, recordType?: string }} line
377
+ * @returns {boolean}
378
+ */
379
+ static #isElectricalWireLine(line) {
380
+ if (line?.ownerIndex || line?.isBus === true) {
381
+ return false
382
+ }
383
+
384
+ if (!Object.prototype.hasOwnProperty.call(line || {}, 'recordType')) {
385
+ return true
386
+ }
387
+
388
+ return !['6', '7', '26'].includes(String(line.recordType || ''))
389
+ }
390
+
373
391
  /**
374
392
  * Builds a stable map key for one point.
375
393
  * @param {{ x: number, y: number }} point