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.
- package/package.json +1 -1
- package/src/core/altium/AltiumParser.mjs +15 -3
- package/src/core/altium/PcbFontMetricsParser.mjs +33 -6
- package/src/core/altium/PcbModelParser.mjs +82 -0
- package/src/core/altium/PcbTextPrimitiveParser.mjs +48 -3
- package/src/core/altium/SchematicPinDesignatorInferer.mjs +401 -0
- package/src/core/altium/SchematicPinParser.mjs +136 -56
- package/src/core/altium/SchematicStreamExtractor.mjs +93 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +40 -12
- package/src/scene3d.mjs +1 -0
- package/src/ui/PcbScene3dBuilder.mjs +574 -145
- package/src/ui/PcbScene3dDrillCutoutBuilder.mjs +453 -0
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +272 -0
- package/src/ui/PcbScene3dTextBoxLayoutResolver.mjs +95 -0
- package/src/ui/SchematicImageRenderer.mjs +134 -32
- package/src/ui/SchematicJunctionRenderer.mjs +22 -4
- package/src/ui/SchematicNoteRenderer.mjs +73 -9
- package/src/ui/SchematicPinSvgRenderer.mjs +25 -3
- package/src/ui/SchematicPowerPortRenderer.mjs +183 -36
- package/src/ui/SchematicSvgRenderer.mjs +2 -2
|
@@ -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
|
-
'<
|
|
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
|
-
'"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'
|
|
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)
|
|
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
|