altium-toolkit 1.4.10 → 1.4.12
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/docs/release-notes-v1.4.11.md +34 -0
- package/docs/release-notes-v1.4.12.md +29 -0
- package/package.json +1 -1
- package/src/convergence/AltiumSchematicFidelityNormalizer.mjs +696 -0
- package/src/convergence/SchematicSvgRenderer.mjs +53 -5
- package/src/ui/SchematicHarnessRenderer.mjs +346 -0
- package/src/ui/SchematicRotatedOwnerTextPlacement.mjs +55 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# altium-toolkit 1.4.11
|
|
2
|
+
|
|
3
|
+
Version 1.4.11 restores complete native schematic fidelity for embedded
|
|
4
|
+
template frames, footer metadata, signal harnesses, and rotated passive text.
|
|
5
|
+
|
|
6
|
+
## Native schematic fidelity
|
|
7
|
+
|
|
8
|
+
- Structurally proven embedded template dimensions remain the authored render
|
|
9
|
+
frame instead of being replaced by a sparse-content estimate.
|
|
10
|
+
- Complete footer owner groups resolve organization, address, approval, and
|
|
11
|
+
other native metadata rows from the ownership sidecar.
|
|
12
|
+
- Signal harness trunks, connector brackets, entries, and type labels render as
|
|
13
|
+
first-class SVG primitives with native additional-list ownership.
|
|
14
|
+
- Right-side vertical component parameters clear narrow passive bodies while
|
|
15
|
+
left-side designators retain their authored columns.
|
|
16
|
+
|
|
17
|
+
## Compatibility
|
|
18
|
+
|
|
19
|
+
- Historical parser and renderer sources remain byte-for-byte frozen.
|
|
20
|
+
- Repairs are isolated to the convergence layer used by current toolkit
|
|
21
|
+
consumers and do not change public parser or renderer signatures.
|
|
22
|
+
- Geometry and ownership decisions derive from native structure rather than
|
|
23
|
+
project names, labels, filenames, or fixture-specific values.
|
|
24
|
+
- Existing schematic theme variables, palette behavior, canvas border, and
|
|
25
|
+
title-block chrome remain unchanged.
|
|
26
|
+
|
|
27
|
+
## Verification
|
|
28
|
+
|
|
29
|
+
- Obfuscated repository-owned regressions cover native-frame proof, complete
|
|
30
|
+
footer owners, implicit harness children, themed harness SVG, and vertical
|
|
31
|
+
passive annotation columns.
|
|
32
|
+
- The complete package suite, immutable-source checks, feature-preservation
|
|
33
|
+
check, formatting check, performance guard, and npm package dry run are
|
|
34
|
+
required for release.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# altium-toolkit 1.4.12
|
|
2
|
+
|
|
3
|
+
Version 1.4.12 preserves native schematic footer metadata when a project also
|
|
4
|
+
provides document-level special-string parameters.
|
|
5
|
+
|
|
6
|
+
## Project schematic fidelity
|
|
7
|
+
|
|
8
|
+
- Project parameters are resolved before the convergence fidelity pass so
|
|
9
|
+
document context and native fallback metadata compose deterministically.
|
|
10
|
+
- Footer fallback values synchronize the visible text, resolved-text sidecar,
|
|
11
|
+
and special-string expression before the historical renderer consumes them.
|
|
12
|
+
- Organization, address, approval, and other native owner metadata no longer
|
|
13
|
+
regress to unresolved placeholders in complete project loads.
|
|
14
|
+
|
|
15
|
+
## Compatibility
|
|
16
|
+
|
|
17
|
+
- Historical parser and renderer sources remain byte-for-byte frozen.
|
|
18
|
+
- Existing schematic colors, canvas borders, title-block chrome, and public
|
|
19
|
+
renderer signatures remain unchanged.
|
|
20
|
+
- Resolution derives from the ownership and parameter data models without
|
|
21
|
+
project names, filenames, labels, or sample-specific rules.
|
|
22
|
+
|
|
23
|
+
## Verification
|
|
24
|
+
|
|
25
|
+
- A repository-owned generic regression covers project parameters combined
|
|
26
|
+
with native footer metadata fallbacks.
|
|
27
|
+
- The complete package suite, immutable-source checks, feature-preservation
|
|
28
|
+
check, formatting check, performance guard, and npm package dry run are
|
|
29
|
+
required for release.
|
package/package.json
CHANGED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import { ParserUtils } from '../core/altium/ParserUtils.mjs'
|
|
5
|
+
import { SchematicTypography } from '../ui/SchematicTypography.mjs'
|
|
6
|
+
import { SchematicRotatedOwnerTextPlacement } from '../ui/SchematicRotatedOwnerTextPlacement.mjs'
|
|
7
|
+
|
|
8
|
+
const PRIMITIVE_FAMILIES = Object.freeze([
|
|
9
|
+
'lines',
|
|
10
|
+
'polygons',
|
|
11
|
+
'rectangles',
|
|
12
|
+
'roundedRectangles',
|
|
13
|
+
'ellipses',
|
|
14
|
+
'arcs',
|
|
15
|
+
'pies'
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Repairs render-only schematic fidelity from the native ownership sidecar
|
|
20
|
+
* without changing the preserved historical parser or renderer contracts.
|
|
21
|
+
*/
|
|
22
|
+
export class AltiumSchematicFidelityNormalizer {
|
|
23
|
+
/**
|
|
24
|
+
* Builds a shallow normalized view for convergence rendering.
|
|
25
|
+
* @param {Record<string, any>} documentModel Native renderer document.
|
|
26
|
+
* @returns {Record<string, any>} Fidelity-normalized render document.
|
|
27
|
+
*/
|
|
28
|
+
static normalize(documentModel) {
|
|
29
|
+
const schematic = documentModel?.schematic
|
|
30
|
+
if (!schematic) return documentModel
|
|
31
|
+
|
|
32
|
+
const records = schematic.ownership?.records || []
|
|
33
|
+
const sheet = AltiumSchematicFidelityNormalizer.#normalizeSheet(
|
|
34
|
+
schematic,
|
|
35
|
+
records
|
|
36
|
+
)
|
|
37
|
+
const ownerBounds =
|
|
38
|
+
AltiumSchematicFidelityNormalizer.#collectOwnerBounds(schematic)
|
|
39
|
+
const footerTexts =
|
|
40
|
+
AltiumSchematicFidelityNormalizer.#resolveFooterTexts(
|
|
41
|
+
schematic.texts || [],
|
|
42
|
+
records,
|
|
43
|
+
sheet
|
|
44
|
+
)
|
|
45
|
+
const texts = AltiumSchematicFidelityNormalizer.#placeOwnerTexts(
|
|
46
|
+
footerTexts,
|
|
47
|
+
ownerBounds
|
|
48
|
+
).filter((text) => String(text?.recordType || '') !== '217')
|
|
49
|
+
const harnesses = AltiumSchematicFidelityNormalizer.#normalizeHarnesses(
|
|
50
|
+
schematic.harnesses,
|
|
51
|
+
records
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
sheet === schematic.sheet &&
|
|
56
|
+
texts === schematic.texts &&
|
|
57
|
+
harnesses === schematic.harnesses
|
|
58
|
+
) {
|
|
59
|
+
return documentModel
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
...documentModel,
|
|
64
|
+
schematic: {
|
|
65
|
+
...schematic,
|
|
66
|
+
sheet,
|
|
67
|
+
texts,
|
|
68
|
+
...(harnesses ? { harnesses } : {})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Restores a proven embedded native frame from source sheet dimensions.
|
|
75
|
+
* @param {Record<string, any>} schematic Native schematic model.
|
|
76
|
+
* @param {Record<string, any>[]} records Ownership records.
|
|
77
|
+
* @returns {Record<string, any>} Original or normalized sheet.
|
|
78
|
+
*/
|
|
79
|
+
static #normalizeSheet(schematic, records) {
|
|
80
|
+
const sheet = schematic.sheet || {}
|
|
81
|
+
const sourceWidth = Number(sheet.sourceWidth || 0)
|
|
82
|
+
const sourceHeight = Number(sheet.sourceHeight || 0)
|
|
83
|
+
const margin = Math.max(Number(sheet.marginWidth || 20), 20)
|
|
84
|
+
const sheetRecord = records.find(
|
|
85
|
+
(record) =>
|
|
86
|
+
AltiumSchematicFidelityNormalizer.#recordType(record) === '31'
|
|
87
|
+
)
|
|
88
|
+
const sheetStyle = Number(
|
|
89
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
90
|
+
sheetRecord?.fields,
|
|
91
|
+
'SheetStyle'
|
|
92
|
+
) || 0
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
sheetStyle !== 1 ||
|
|
97
|
+
!sheet.borderOn ||
|
|
98
|
+
!sheet.paperSize ||
|
|
99
|
+
sourceWidth <= margin * 2 ||
|
|
100
|
+
sourceHeight <= margin * 2 ||
|
|
101
|
+
Number(sheet.width) >= Number(sheet.height) !==
|
|
102
|
+
sourceWidth >= sourceHeight ||
|
|
103
|
+
!AltiumSchematicFidelityNormalizer.#hasNativeFrameEdge(
|
|
104
|
+
schematic,
|
|
105
|
+
sourceWidth,
|
|
106
|
+
sourceHeight,
|
|
107
|
+
margin
|
|
108
|
+
)
|
|
109
|
+
) {
|
|
110
|
+
return sheet
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (
|
|
114
|
+
Number(sheet.width) === sourceWidth &&
|
|
115
|
+
Number(sheet.height) === sourceHeight
|
|
116
|
+
) {
|
|
117
|
+
return sheet
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
...sheet,
|
|
122
|
+
width: sourceWidth,
|
|
123
|
+
height: sourceHeight,
|
|
124
|
+
sourceWidth,
|
|
125
|
+
sourceHeight
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Returns true when owner chrome reaches the stored native frame edge and
|
|
131
|
+
* all authored geometry remains inside that frame.
|
|
132
|
+
* @param {Record<string, any>} schematic Native schematic model.
|
|
133
|
+
* @param {number} sourceWidth Stored sheet width.
|
|
134
|
+
* @param {number} sourceHeight Stored sheet height.
|
|
135
|
+
* @param {number} margin Sheet margin.
|
|
136
|
+
* @returns {boolean} Whether the source frame is structurally proven.
|
|
137
|
+
*/
|
|
138
|
+
static #hasNativeFrameEdge(schematic, sourceWidth, sourceHeight, margin) {
|
|
139
|
+
const primitives = PRIMITIVE_FAMILIES.flatMap(
|
|
140
|
+
(family) => schematic[family] || []
|
|
141
|
+
)
|
|
142
|
+
const bounds = primitives
|
|
143
|
+
.map((primitive) => ({
|
|
144
|
+
ownerIndex: String(primitive?.ownerIndex || '').trim(),
|
|
145
|
+
bounds: AltiumSchematicFidelityNormalizer.#bounds(primitive)
|
|
146
|
+
}))
|
|
147
|
+
.filter((entry) => entry.bounds)
|
|
148
|
+
const frameEdge = sourceWidth - margin
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
bounds.some(
|
|
152
|
+
(entry) =>
|
|
153
|
+
entry.ownerIndex &&
|
|
154
|
+
Math.abs(entry.bounds.maxX - frameEdge) <= 0.01
|
|
155
|
+
) &&
|
|
156
|
+
bounds.every(
|
|
157
|
+
(entry) =>
|
|
158
|
+
entry.bounds.maxX <= sourceWidth - margin + 0.01 &&
|
|
159
|
+
entry.bounds.maxY <= sourceHeight - margin + 0.01
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolves every text placeholder in a native footer owner group.
|
|
166
|
+
* @param {Record<string, any>[]} texts Normalized visible texts.
|
|
167
|
+
* @param {Record<string, any>[]} records Ownership records.
|
|
168
|
+
* @param {Record<string, any>} sheet Normalized sheet.
|
|
169
|
+
* @returns {Record<string, any>[]} Original or resolved texts.
|
|
170
|
+
*/
|
|
171
|
+
static #resolveFooterTexts(texts, records, sheet) {
|
|
172
|
+
const metadata = new Map()
|
|
173
|
+
for (const record of records) {
|
|
174
|
+
if (AltiumSchematicFidelityNormalizer.#owner(record)) continue
|
|
175
|
+
const name = AltiumSchematicFidelityNormalizer.#field(
|
|
176
|
+
record.fields,
|
|
177
|
+
'Name'
|
|
178
|
+
)
|
|
179
|
+
const value = AltiumSchematicFidelityNormalizer.#field(
|
|
180
|
+
record.fields,
|
|
181
|
+
'Text'
|
|
182
|
+
)
|
|
183
|
+
if (name && value) {
|
|
184
|
+
metadata.set(name.toLowerCase(), value)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const footerOwners = new Set(
|
|
189
|
+
records
|
|
190
|
+
.filter((record) =>
|
|
191
|
+
AltiumSchematicFidelityNormalizer.#isFooterSeedRecord(
|
|
192
|
+
record,
|
|
193
|
+
sheet
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
.map((record) =>
|
|
197
|
+
AltiumSchematicFidelityNormalizer.#owner(record)
|
|
198
|
+
)
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
)
|
|
201
|
+
if (!footerOwners.size || !metadata.size) return texts
|
|
202
|
+
|
|
203
|
+
let changed = false
|
|
204
|
+
const resolvedTexts = texts.map((text) => {
|
|
205
|
+
const sourceText = String(text?.text || '').trim()
|
|
206
|
+
if (
|
|
207
|
+
!footerOwners.has(String(text?.ownerIndex || '').trim()) ||
|
|
208
|
+
!sourceText.startsWith('=')
|
|
209
|
+
) {
|
|
210
|
+
return text
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const replacement = metadata.get(sourceText.slice(1).toLowerCase())
|
|
214
|
+
if (!replacement) return text
|
|
215
|
+
|
|
216
|
+
changed = true
|
|
217
|
+
return AltiumSchematicFidelityNormalizer.#resolvedFooterText(
|
|
218
|
+
text,
|
|
219
|
+
replacement
|
|
220
|
+
)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
return changed ? resolvedTexts : texts
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Synchronizes one footer fallback across the visible text sidecar fields.
|
|
228
|
+
* @param {Record<string, any>} text Footer text primitive.
|
|
229
|
+
* @param {string} replacement Resolved native metadata value.
|
|
230
|
+
* @returns {Record<string, any>} Resolved footer text primitive.
|
|
231
|
+
*/
|
|
232
|
+
static #resolvedFooterText(text, replacement) {
|
|
233
|
+
const specialString = text?.specialString
|
|
234
|
+
const resolvedSpecialString = specialString
|
|
235
|
+
? {
|
|
236
|
+
...specialString,
|
|
237
|
+
resolvedText: replacement,
|
|
238
|
+
expressionParts: Array.isArray(specialString.expressionParts)
|
|
239
|
+
? specialString.expressionParts.map((part) =>
|
|
240
|
+
part?.kind === 'parameter'
|
|
241
|
+
? { ...part, value: replacement }
|
|
242
|
+
: part
|
|
243
|
+
)
|
|
244
|
+
: specialString.expressionParts
|
|
245
|
+
}
|
|
246
|
+
: specialString
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
...text,
|
|
250
|
+
text: replacement,
|
|
251
|
+
resolvedText: replacement,
|
|
252
|
+
...(resolvedSpecialString
|
|
253
|
+
? { specialString: resolvedSpecialString }
|
|
254
|
+
: {})
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Returns true when an ownership record seeds the lower-right footer.
|
|
260
|
+
* @param {Record<string, any>} record Ownership record.
|
|
261
|
+
* @param {Record<string, any>} sheet Sheet metadata.
|
|
262
|
+
* @returns {boolean} Whether the record belongs to a footer owner.
|
|
263
|
+
*/
|
|
264
|
+
static #isFooterSeedRecord(record, sheet) {
|
|
265
|
+
if (AltiumSchematicFidelityNormalizer.#recordType(record) !== '4') {
|
|
266
|
+
return false
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const x = Number(
|
|
270
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
271
|
+
record.fields,
|
|
272
|
+
'Location.X'
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
const y = Number(
|
|
276
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
277
|
+
record.fields,
|
|
278
|
+
'Location.Y'
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
Number.isFinite(x) &&
|
|
284
|
+
Number.isFinite(y) &&
|
|
285
|
+
x >= Number(sheet?.width || 0) * 0.55 &&
|
|
286
|
+
y <= 100
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Moves right-side vertical component parameters clear of owner bodies.
|
|
292
|
+
* @param {Record<string, any>[]} texts Schematic text rows.
|
|
293
|
+
* @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerBounds Owner bounds.
|
|
294
|
+
* @returns {Record<string, any>[]} Original or placed texts.
|
|
295
|
+
*/
|
|
296
|
+
static #placeOwnerTexts(texts, ownerBounds) {
|
|
297
|
+
let changed = false
|
|
298
|
+
const placedTexts = texts.map((text) => {
|
|
299
|
+
const viewerFontSize =
|
|
300
|
+
SchematicTypography.resolveViewerFontSize(text?.fontSize) || 0
|
|
301
|
+
const x = SchematicRotatedOwnerTextPlacement.resolveX(
|
|
302
|
+
text,
|
|
303
|
+
text?.x,
|
|
304
|
+
viewerFontSize,
|
|
305
|
+
ownerBounds
|
|
306
|
+
)
|
|
307
|
+
if (x === Number(text?.x)) return text
|
|
308
|
+
|
|
309
|
+
changed = true
|
|
310
|
+
return { ...text, x }
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
return changed ? placedTexts : texts
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Completes harness child ownership from explicit and additional-list rows.
|
|
318
|
+
* @param {Record<string, any> | null | undefined} harnesses Harness model.
|
|
319
|
+
* @param {Record<string, any>[]} records Ownership records.
|
|
320
|
+
* @returns {Record<string, any> | null | undefined} Completed harness model.
|
|
321
|
+
*/
|
|
322
|
+
static #normalizeHarnesses(harnesses, records) {
|
|
323
|
+
if (!harnesses?.connectors?.length) return harnesses
|
|
324
|
+
|
|
325
|
+
let changed = false
|
|
326
|
+
const connectors = harnesses.connectors.map((connector) => {
|
|
327
|
+
const connectorRecord = records.find(
|
|
328
|
+
(record) =>
|
|
329
|
+
record.key === connector.recordKey ||
|
|
330
|
+
String(record.recordId || '') ===
|
|
331
|
+
String(connector.recordId || '')
|
|
332
|
+
)
|
|
333
|
+
if (!connectorRecord) return connector
|
|
334
|
+
|
|
335
|
+
const children = AltiumSchematicFidelityNormalizer.#harnessChildren(
|
|
336
|
+
connectorRecord,
|
|
337
|
+
records
|
|
338
|
+
)
|
|
339
|
+
const entryRecords = children.filter(
|
|
340
|
+
(record) =>
|
|
341
|
+
AltiumSchematicFidelityNormalizer.#recordType(record) ===
|
|
342
|
+
'216'
|
|
343
|
+
)
|
|
344
|
+
const typeRecord = children.find(
|
|
345
|
+
(record) =>
|
|
346
|
+
AltiumSchematicFidelityNormalizer.#recordType(record) ===
|
|
347
|
+
'217'
|
|
348
|
+
)
|
|
349
|
+
if (!entryRecords.length && !typeRecord) return connector
|
|
350
|
+
|
|
351
|
+
changed = true
|
|
352
|
+
return {
|
|
353
|
+
...connector,
|
|
354
|
+
entries: entryRecords.map((record) =>
|
|
355
|
+
AltiumSchematicFidelityNormalizer.#harnessEntry(record)
|
|
356
|
+
),
|
|
357
|
+
...(typeRecord
|
|
358
|
+
? {
|
|
359
|
+
typeLabel:
|
|
360
|
+
AltiumSchematicFidelityNormalizer.#harnessTypeLabel(
|
|
361
|
+
typeRecord
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
: {})
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
if (!changed) return harnesses
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
...harnesses,
|
|
371
|
+
connectors,
|
|
372
|
+
bundleLinks: (harnesses.bundleLinks || []).map((link, index) => {
|
|
373
|
+
const connector = connectors[index]
|
|
374
|
+
return {
|
|
375
|
+
...link,
|
|
376
|
+
harnessType:
|
|
377
|
+
connector?.typeLabel?.text ||
|
|
378
|
+
connector?.entries?.find((entry) => entry.harnessType)
|
|
379
|
+
?.harnessType,
|
|
380
|
+
entries: (connector?.entries || []).map(
|
|
381
|
+
(entry) => entry.name
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Collects connector children using native explicit and list ownership.
|
|
390
|
+
* @param {Record<string, any>} connectorRecord Connector ownership row.
|
|
391
|
+
* @param {Record<string, any>[]} records Ownership records.
|
|
392
|
+
* @returns {Record<string, any>[]} Child rows.
|
|
393
|
+
*/
|
|
394
|
+
static #harnessChildren(connectorRecord, records) {
|
|
395
|
+
const position = records.indexOf(connectorRecord)
|
|
396
|
+
const ownerKeys = new Set([
|
|
397
|
+
String(connectorRecord.recordIndex ?? ''),
|
|
398
|
+
String(Number(connectorRecord.recordIndex ?? -1) + 1),
|
|
399
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
400
|
+
connectorRecord.fields,
|
|
401
|
+
'IndexInSheet'
|
|
402
|
+
),
|
|
403
|
+
String(
|
|
404
|
+
Number(
|
|
405
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
406
|
+
connectorRecord.fields,
|
|
407
|
+
'IndexInSheet'
|
|
408
|
+
) || -1
|
|
409
|
+
) + 1
|
|
410
|
+
)
|
|
411
|
+
])
|
|
412
|
+
const children = records.filter((record) => {
|
|
413
|
+
const recordType =
|
|
414
|
+
AltiumSchematicFidelityNormalizer.#recordType(record)
|
|
415
|
+
return (
|
|
416
|
+
(recordType === '216' || recordType === '217') &&
|
|
417
|
+
ownerKeys.has(AltiumSchematicFidelityNormalizer.#owner(record))
|
|
418
|
+
)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
for (let index = position + 1; position >= 0; index += 1) {
|
|
422
|
+
const record = records[index]
|
|
423
|
+
const recordType =
|
|
424
|
+
AltiumSchematicFidelityNormalizer.#recordType(record)
|
|
425
|
+
if (recordType !== '216' && recordType !== '217') break
|
|
426
|
+
if (
|
|
427
|
+
AltiumSchematicFidelityNormalizer.#owner(record) ||
|
|
428
|
+
!ParserUtils.parseBoolean(
|
|
429
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
430
|
+
record.fields,
|
|
431
|
+
'OwnerIndexAdditionalList'
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
) {
|
|
435
|
+
break
|
|
436
|
+
}
|
|
437
|
+
if (!children.includes(record)) children.push(record)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return children
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Builds one normalized harness entry.
|
|
445
|
+
* @param {Record<string, any>} record Ownership row.
|
|
446
|
+
* @returns {Record<string, any>} Harness entry.
|
|
447
|
+
*/
|
|
448
|
+
static #harnessEntry(record) {
|
|
449
|
+
const fields = record.fields || {}
|
|
450
|
+
const whole = Number(
|
|
451
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
452
|
+
fields,
|
|
453
|
+
'DistanceFromTop'
|
|
454
|
+
) || 0
|
|
455
|
+
)
|
|
456
|
+
const fraction = Number(
|
|
457
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
458
|
+
fields,
|
|
459
|
+
'DistanceFromTop_Frac1'
|
|
460
|
+
) || 0
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
return AltiumSchematicFidelityNormalizer.#stripEmpty({
|
|
464
|
+
key: 'harness-entry-' + String(record.recordIndex ?? 0),
|
|
465
|
+
recordKey: record.key,
|
|
466
|
+
name: AltiumSchematicFidelityNormalizer.#field(fields, 'Name'),
|
|
467
|
+
side: AltiumSchematicFidelityNormalizer.#side(
|
|
468
|
+
AltiumSchematicFidelityNormalizer.#field(fields, 'Side')
|
|
469
|
+
),
|
|
470
|
+
distanceFromTop: Number(
|
|
471
|
+
(whole * 10 + fraction / 100000).toFixed(4)
|
|
472
|
+
),
|
|
473
|
+
harnessType: AltiumSchematicFidelityNormalizer.#field(
|
|
474
|
+
fields,
|
|
475
|
+
'HarnessType'
|
|
476
|
+
),
|
|
477
|
+
textStyle: AltiumSchematicFidelityNormalizer.#textStyle(
|
|
478
|
+
AltiumSchematicFidelityNormalizer.#field(fields, 'TextStyle')
|
|
479
|
+
),
|
|
480
|
+
textColor: ParserUtils.toColor(fields.TEXTCOLOR, '#000000')
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Builds one normalized harness type label.
|
|
486
|
+
* @param {Record<string, any>} record Ownership row.
|
|
487
|
+
* @returns {Record<string, any>} Harness type label.
|
|
488
|
+
*/
|
|
489
|
+
static #harnessTypeLabel(record) {
|
|
490
|
+
const fields = record.fields || {}
|
|
491
|
+
return {
|
|
492
|
+
key: 'harness-type-' + String(record.recordIndex ?? 0),
|
|
493
|
+
recordKey: record.key,
|
|
494
|
+
text: AltiumSchematicFidelityNormalizer.#field(fields, 'Text'),
|
|
495
|
+
x: Number(
|
|
496
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
497
|
+
fields,
|
|
498
|
+
'Location.X'
|
|
499
|
+
) || 0
|
|
500
|
+
),
|
|
501
|
+
y: Number(
|
|
502
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
503
|
+
fields,
|
|
504
|
+
'Location.Y'
|
|
505
|
+
) || 0
|
|
506
|
+
),
|
|
507
|
+
color: ParserUtils.toColor(fields.COLOR, '#000000')
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Collects owner envelopes for rotated parameter placement.
|
|
513
|
+
* @param {Record<string, any>} schematic Native schematic model.
|
|
514
|
+
* @returns {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>}
|
|
515
|
+
*/
|
|
516
|
+
static #collectOwnerBounds(schematic) {
|
|
517
|
+
const ownerBounds = new Map()
|
|
518
|
+
for (const primitive of PRIMITIVE_FAMILIES.flatMap(
|
|
519
|
+
(family) => schematic[family] || []
|
|
520
|
+
)) {
|
|
521
|
+
const owner = String(primitive?.ownerIndex || '').trim()
|
|
522
|
+
const bounds = AltiumSchematicFidelityNormalizer.#bounds(primitive)
|
|
523
|
+
if (!owner || !bounds) continue
|
|
524
|
+
|
|
525
|
+
const current = ownerBounds.get(owner)
|
|
526
|
+
if (!current) {
|
|
527
|
+
ownerBounds.set(owner, { ...bounds })
|
|
528
|
+
continue
|
|
529
|
+
}
|
|
530
|
+
current.minX = Math.min(current.minX, bounds.minX)
|
|
531
|
+
current.minY = Math.min(current.minY, bounds.minY)
|
|
532
|
+
current.maxX = Math.max(current.maxX, bounds.maxX)
|
|
533
|
+
current.maxY = Math.max(current.maxY, bounds.maxY)
|
|
534
|
+
}
|
|
535
|
+
return ownerBounds
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Resolves primitive coordinate bounds.
|
|
540
|
+
* @param {Record<string, any>} primitive Primitive row.
|
|
541
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
542
|
+
*/
|
|
543
|
+
static #bounds(primitive) {
|
|
544
|
+
const points = []
|
|
545
|
+
if (Array.isArray(primitive?.points)) points.push(...primitive.points)
|
|
546
|
+
if (
|
|
547
|
+
Number.isFinite(Number(primitive?.x1)) &&
|
|
548
|
+
Number.isFinite(Number(primitive?.y1)) &&
|
|
549
|
+
Number.isFinite(Number(primitive?.x2)) &&
|
|
550
|
+
Number.isFinite(Number(primitive?.y2))
|
|
551
|
+
) {
|
|
552
|
+
points.push(
|
|
553
|
+
{ x: Number(primitive.x1), y: Number(primitive.y1) },
|
|
554
|
+
{ x: Number(primitive.x2), y: Number(primitive.y2) }
|
|
555
|
+
)
|
|
556
|
+
} else if (
|
|
557
|
+
Number.isFinite(Number(primitive?.x)) &&
|
|
558
|
+
Number.isFinite(Number(primitive?.y)) &&
|
|
559
|
+
Number.isFinite(Number(primitive?.width)) &&
|
|
560
|
+
Number.isFinite(Number(primitive?.height))
|
|
561
|
+
) {
|
|
562
|
+
points.push(
|
|
563
|
+
{ x: Number(primitive.x), y: Number(primitive.y) },
|
|
564
|
+
{
|
|
565
|
+
x: Number(primitive.x) + Number(primitive.width),
|
|
566
|
+
y: Number(primitive.y) + Number(primitive.height)
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
} else if (
|
|
570
|
+
Number.isFinite(Number(primitive?.x)) &&
|
|
571
|
+
Number.isFinite(Number(primitive?.y)) &&
|
|
572
|
+
(Number.isFinite(Number(primitive?.radius)) ||
|
|
573
|
+
Number.isFinite(Number(primitive?.radiusX)))
|
|
574
|
+
) {
|
|
575
|
+
const radiusX = Math.abs(
|
|
576
|
+
Number(primitive.radiusX ?? primitive.radius)
|
|
577
|
+
)
|
|
578
|
+
const radiusY = Math.abs(
|
|
579
|
+
Number(primitive.radiusY ?? primitive.radius)
|
|
580
|
+
)
|
|
581
|
+
points.push(
|
|
582
|
+
{
|
|
583
|
+
x: Number(primitive.x) - radiusX,
|
|
584
|
+
y: Number(primitive.y) - radiusY
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
x: Number(primitive.x) + radiusX,
|
|
588
|
+
y: Number(primitive.y) + radiusY
|
|
589
|
+
}
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const finitePoints = points.filter(
|
|
594
|
+
(point) =>
|
|
595
|
+
Number.isFinite(Number(point?.x)) &&
|
|
596
|
+
Number.isFinite(Number(point?.y))
|
|
597
|
+
)
|
|
598
|
+
if (!finitePoints.length) return null
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
minX: Math.min(...finitePoints.map((point) => Number(point.x))),
|
|
602
|
+
minY: Math.min(...finitePoints.map((point) => Number(point.y))),
|
|
603
|
+
maxX: Math.max(...finitePoints.map((point) => Number(point.x))),
|
|
604
|
+
maxY: Math.max(...finitePoints.map((point) => Number(point.y)))
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Resolves one ownership record type.
|
|
610
|
+
* @param {Record<string, any> | undefined} record Ownership row.
|
|
611
|
+
* @returns {string} Record type.
|
|
612
|
+
*/
|
|
613
|
+
static #recordType(record) {
|
|
614
|
+
return String(
|
|
615
|
+
record?.recordType ??
|
|
616
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
617
|
+
record?.fields,
|
|
618
|
+
'Record'
|
|
619
|
+
) ??
|
|
620
|
+
''
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Resolves one ownership key.
|
|
626
|
+
* @param {Record<string, any> | undefined} record Ownership row.
|
|
627
|
+
* @returns {string} Owner key.
|
|
628
|
+
*/
|
|
629
|
+
static #owner(record) {
|
|
630
|
+
return String(
|
|
631
|
+
record?.ownerIndex ??
|
|
632
|
+
AltiumSchematicFidelityNormalizer.#field(
|
|
633
|
+
record?.fields,
|
|
634
|
+
'OwnerIndex'
|
|
635
|
+
) ??
|
|
636
|
+
''
|
|
637
|
+
).trim()
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Reads one case-insensitive raw field.
|
|
642
|
+
* @param {Record<string, any> | undefined} fields Raw fields.
|
|
643
|
+
* @param {string} name Field name.
|
|
644
|
+
* @returns {string} Field value.
|
|
645
|
+
*/
|
|
646
|
+
static #field(fields, name) {
|
|
647
|
+
if (!fields) return ''
|
|
648
|
+
const key = Object.keys(fields).find(
|
|
649
|
+
(candidate) => candidate.toLowerCase() === name.toLowerCase()
|
|
650
|
+
)
|
|
651
|
+
const value = key ? fields[key] : ''
|
|
652
|
+
return String(Array.isArray(value) ? value.at(-1) || '' : value || '')
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Resolves native side codes.
|
|
657
|
+
* @param {string | number} value Native side code.
|
|
658
|
+
* @returns {'left' | 'right' | 'top' | 'bottom'} Side label.
|
|
659
|
+
*/
|
|
660
|
+
static #side(value) {
|
|
661
|
+
return ['left', 'right', 'top', 'bottom'][Number(value)] || 'left'
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Resolves native harness entry text style.
|
|
666
|
+
* @param {string} value Native style.
|
|
667
|
+
* @returns {string} Normalized style.
|
|
668
|
+
*/
|
|
669
|
+
static #textStyle(value) {
|
|
670
|
+
const normalized = String(value || '').toLowerCase()
|
|
671
|
+
if (normalized === '1' || normalized === 'abbreviated') {
|
|
672
|
+
return 'abbreviated'
|
|
673
|
+
}
|
|
674
|
+
if (normalized === '2' || normalized === 'short') return 'short'
|
|
675
|
+
return 'full'
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Removes empty fields while retaining false and zero.
|
|
680
|
+
* @param {Record<string, any>} value Candidate record.
|
|
681
|
+
* @returns {Record<string, any>} Compact record.
|
|
682
|
+
*/
|
|
683
|
+
static #stripEmpty(value) {
|
|
684
|
+
return Object.fromEntries(
|
|
685
|
+
Object.entries(value).filter(
|
|
686
|
+
([, fieldValue]) =>
|
|
687
|
+
fieldValue !== null &&
|
|
688
|
+
fieldValue !== undefined &&
|
|
689
|
+
fieldValue !== ''
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
Object.freeze(AltiumSchematicFidelityNormalizer.prototype)
|
|
696
|
+
Object.freeze(AltiumSchematicFidelityNormalizer)
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
3
|
|
|
4
4
|
import { SchematicSvgRenderer as LegacySchematicSvgRenderer } from '../ui/SchematicSvgRenderer.mjs'
|
|
5
|
+
import { SchematicProjectParameterResolver } from '../core/altium/SchematicProjectParameterResolver.mjs'
|
|
5
6
|
import { AltiumSchematicImageNormalizer } from './AltiumSchematicImageNormalizer.mjs'
|
|
6
7
|
import { AltiumSchematicNativeFooterOwnerAligner } from './AltiumSchematicNativeFooterOwnerAligner.mjs'
|
|
8
|
+
import { AltiumSchematicFidelityNormalizer } from './AltiumSchematicFidelityNormalizer.mjs'
|
|
9
|
+
import { SchematicHarnessRenderer } from '../ui/SchematicHarnessRenderer.mjs'
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Renders native Altium schematic models through the preserved historical
|
|
@@ -19,13 +22,58 @@ export class SchematicSvgRenderer {
|
|
|
19
22
|
* @returns {string} Rendered SVG panel markup.
|
|
20
23
|
*/
|
|
21
24
|
static render(documentModel, options = {}) {
|
|
25
|
+
const projectParameters = options.projectParameters
|
|
26
|
+
const projectResolved = projectParameters
|
|
27
|
+
? SchematicProjectParameterResolver.applyToDocumentModel(
|
|
28
|
+
documentModel,
|
|
29
|
+
projectParameters,
|
|
30
|
+
{ replaceText: true }
|
|
31
|
+
)
|
|
32
|
+
: documentModel
|
|
22
33
|
const normalized =
|
|
23
|
-
AltiumSchematicImageNormalizer.normalize(
|
|
34
|
+
AltiumSchematicImageNormalizer.normalize(projectResolved)
|
|
35
|
+
const fidelityNormalized =
|
|
36
|
+
AltiumSchematicFidelityNormalizer.normalize(normalized)
|
|
24
37
|
const aligned =
|
|
25
|
-
AltiumSchematicNativeFooterOwnerAligner.align(
|
|
26
|
-
|
|
27
|
-
SchematicSvgRenderer.#visibilityAwareDocument(aligned)
|
|
28
|
-
|
|
38
|
+
AltiumSchematicNativeFooterOwnerAligner.align(fidelityNormalized)
|
|
39
|
+
const renderDocument =
|
|
40
|
+
SchematicSvgRenderer.#visibilityAwareDocument(aligned)
|
|
41
|
+
const legacyOptions = projectParameters
|
|
42
|
+
? Object.fromEntries(
|
|
43
|
+
Object.entries(options).filter(
|
|
44
|
+
([name]) => name !== 'projectParameters'
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
: options
|
|
48
|
+
const markup = LegacySchematicSvgRenderer.render(
|
|
49
|
+
renderDocument,
|
|
50
|
+
legacyOptions
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return SchematicSvgRenderer.#injectHarnessMarkup(markup, renderDocument)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Inserts first-class harness markup into the preserved SVG hierarchy.
|
|
58
|
+
* @param {string} markup Historical schematic SVG markup.
|
|
59
|
+
* @param {Record<string, any>} documentModel Fidelity-normalized document.
|
|
60
|
+
* @returns {string} SVG markup with harness primitives.
|
|
61
|
+
*/
|
|
62
|
+
static #injectHarnessMarkup(markup, documentModel) {
|
|
63
|
+
if (markup.includes('class="schematic-harnesses"')) return markup
|
|
64
|
+
|
|
65
|
+
const schematic = documentModel?.schematic
|
|
66
|
+
const harnessMarkup = SchematicHarnessRenderer.buildMarkup(
|
|
67
|
+
schematic?.harnesses,
|
|
68
|
+
Number(schematic?.sheet?.height || 0),
|
|
69
|
+
schematic?.sheet || {}
|
|
70
|
+
)
|
|
71
|
+
if (!harnessMarkup) return markup
|
|
72
|
+
|
|
73
|
+
const marker = '<g class="schematic-images">'
|
|
74
|
+
return markup.replace(
|
|
75
|
+
marker,
|
|
76
|
+
'<g class="schematic-harnesses">' + harnessMarkup + '</g>' + marker
|
|
29
77
|
)
|
|
30
78
|
}
|
|
31
79
|
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
|
|
6
|
+
import { SchematicTypography } from './SchematicTypography.mjs'
|
|
7
|
+
import { SchematicColorResolver } from './SchematicColorResolver.mjs'
|
|
8
|
+
|
|
9
|
+
const { createSvgText, escapeHtml, formatNumber, projectSchematicY } =
|
|
10
|
+
SchematicSvgUtils
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders normalized signal harness trunks, connectors, entries, and labels.
|
|
14
|
+
*/
|
|
15
|
+
export class SchematicHarnessRenderer {
|
|
16
|
+
/**
|
|
17
|
+
* Builds complete harness markup.
|
|
18
|
+
* @param {{ connectors?: object[], signalHarnesses?: object[] } | null | undefined} harnesses
|
|
19
|
+
* @param {number} sheetHeight
|
|
20
|
+
* @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
static buildMarkup(harnesses, sheetHeight, sheet) {
|
|
24
|
+
const signalHarnessMarkup = (harnesses?.signalHarnesses || [])
|
|
25
|
+
.map((signalHarness) =>
|
|
26
|
+
SchematicHarnessRenderer.#buildSignalHarnessMarkup(
|
|
27
|
+
signalHarness,
|
|
28
|
+
sheetHeight
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
.join('')
|
|
32
|
+
const connectorMarkup = (harnesses?.connectors || [])
|
|
33
|
+
.map((connector) =>
|
|
34
|
+
SchematicHarnessRenderer.#buildConnectorMarkup(
|
|
35
|
+
connector,
|
|
36
|
+
sheetHeight,
|
|
37
|
+
sheet
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
.join('')
|
|
41
|
+
|
|
42
|
+
return signalHarnessMarkup + connectorMarkup
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Builds one signal-harness polyline.
|
|
47
|
+
* @param {{ points?: { x: number, y: number }[], color?: string, lineWidth?: number }} signalHarness
|
|
48
|
+
* @param {number} sheetHeight
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
static #buildSignalHarnessMarkup(signalHarness, sheetHeight) {
|
|
52
|
+
const points = (signalHarness.points || [])
|
|
53
|
+
.map(
|
|
54
|
+
(point) =>
|
|
55
|
+
formatNumber(point.x) +
|
|
56
|
+
',' +
|
|
57
|
+
formatNumber(projectSchematicY(sheetHeight, point.y))
|
|
58
|
+
)
|
|
59
|
+
.join(' ')
|
|
60
|
+
|
|
61
|
+
if (!points) return ''
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
'<polyline class="schematic-signal-harness" points="' +
|
|
65
|
+
escapeHtml(points) +
|
|
66
|
+
'" fill="none" stroke="' +
|
|
67
|
+
escapeHtml(
|
|
68
|
+
SchematicColorResolver.resolveNonTextColor(
|
|
69
|
+
signalHarness.color,
|
|
70
|
+
'--schematic-default-ink-color',
|
|
71
|
+
true
|
|
72
|
+
)
|
|
73
|
+
) +
|
|
74
|
+
'" stroke-width="' +
|
|
75
|
+
formatNumber(Math.max(Number(signalHarness.lineWidth) || 1, 1)) +
|
|
76
|
+
'" stroke-linecap="round" stroke-linejoin="round" />'
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds one harness connector with its entry labels and type label.
|
|
82
|
+
* @param {{ x: number, y: number, width: number, height: number, side?: 'left' | 'right' | 'top' | 'bottom', primaryConnectionPosition?: number, lineWidth?: number, color?: string, fill?: string, entries?: object[], typeLabel?: object }} connector
|
|
83
|
+
* @param {number} sheetHeight
|
|
84
|
+
* @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
static #buildConnectorMarkup(connector, sheetHeight, sheet) {
|
|
88
|
+
const stroke = SchematicColorResolver.resolveNonTextColor(
|
|
89
|
+
connector.color,
|
|
90
|
+
'--schematic-default-ink-color',
|
|
91
|
+
true
|
|
92
|
+
)
|
|
93
|
+
const fill = SchematicColorResolver.resolveFill(
|
|
94
|
+
connector.fill,
|
|
95
|
+
'--schematic-fill-light-color'
|
|
96
|
+
)
|
|
97
|
+
const textOptions =
|
|
98
|
+
SchematicTypography.buildDefaultSchematicFontOptions(sheet)
|
|
99
|
+
const entryMarkup = (connector.entries || [])
|
|
100
|
+
.map((entry) =>
|
|
101
|
+
SchematicHarnessRenderer.#buildEntryMarkup(
|
|
102
|
+
connector,
|
|
103
|
+
entry,
|
|
104
|
+
sheetHeight,
|
|
105
|
+
textOptions,
|
|
106
|
+
stroke
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
.join('')
|
|
110
|
+
const typeMarkup = connector.typeLabel
|
|
111
|
+
? SchematicHarnessRenderer.#buildTypeLabelMarkup(
|
|
112
|
+
connector.typeLabel,
|
|
113
|
+
sheetHeight,
|
|
114
|
+
textOptions
|
|
115
|
+
)
|
|
116
|
+
: ''
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
'<g class="schematic-harness-connector">' +
|
|
120
|
+
'<polygon points="' +
|
|
121
|
+
escapeHtml(
|
|
122
|
+
SchematicHarnessRenderer.#connectorPoints(
|
|
123
|
+
connector,
|
|
124
|
+
sheetHeight
|
|
125
|
+
)
|
|
126
|
+
) +
|
|
127
|
+
'" fill="' +
|
|
128
|
+
escapeHtml(fill) +
|
|
129
|
+
'" stroke="' +
|
|
130
|
+
escapeHtml(stroke) +
|
|
131
|
+
'" stroke-width="' +
|
|
132
|
+
formatNumber(Math.max(Number(connector.lineWidth) || 1, 1)) +
|
|
133
|
+
'" stroke-linejoin="round" />' +
|
|
134
|
+
entryMarkup +
|
|
135
|
+
typeMarkup +
|
|
136
|
+
'</g>'
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Builds the concave connector outline from its primary connection side.
|
|
142
|
+
* @param {{ x: number, y: number, width: number, height: number, side?: 'left' | 'right' | 'top' | 'bottom', primaryConnectionPosition?: number }} connector
|
|
143
|
+
* @param {number} sheetHeight
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
static #connectorPoints(connector, sheetHeight) {
|
|
147
|
+
const x = Number(connector.x) || 0
|
|
148
|
+
const y = Number(connector.y) || 0
|
|
149
|
+
const width = Math.max(Number(connector.width) || 0, 1)
|
|
150
|
+
const height = Math.max(Number(connector.height) || 0, 1)
|
|
151
|
+
const inset = Math.min(12, width / 3, height / 3)
|
|
152
|
+
const primary = Math.min(
|
|
153
|
+
Math.max(Number(connector.primaryConnectionPosition) || 0, 0),
|
|
154
|
+
connector.side === 'top' || connector.side === 'bottom'
|
|
155
|
+
? width
|
|
156
|
+
: height
|
|
157
|
+
)
|
|
158
|
+
const side = connector.side || 'left'
|
|
159
|
+
let points
|
|
160
|
+
|
|
161
|
+
if (side === 'right') {
|
|
162
|
+
points = [
|
|
163
|
+
{ x, y },
|
|
164
|
+
{ x: x + width - inset, y },
|
|
165
|
+
{ x: x + width, y: y - primary },
|
|
166
|
+
{ x: x + width - inset, y: y - height },
|
|
167
|
+
{ x, y: y - height }
|
|
168
|
+
]
|
|
169
|
+
} else if (side === 'top') {
|
|
170
|
+
points = [
|
|
171
|
+
{ x, y: y - inset },
|
|
172
|
+
{ x: x + primary, y },
|
|
173
|
+
{ x: x + width, y: y - inset },
|
|
174
|
+
{ x: x + width, y: y - height },
|
|
175
|
+
{ x, y: y - height }
|
|
176
|
+
]
|
|
177
|
+
} else if (side === 'bottom') {
|
|
178
|
+
points = [
|
|
179
|
+
{ x, y },
|
|
180
|
+
{ x: x + width, y },
|
|
181
|
+
{ x: x + width, y: y - height + inset },
|
|
182
|
+
{ x: x + primary, y: y - height },
|
|
183
|
+
{ x, y: y - height + inset }
|
|
184
|
+
]
|
|
185
|
+
} else {
|
|
186
|
+
points = [
|
|
187
|
+
{ x: x + inset, y },
|
|
188
|
+
{ x: x + width, y },
|
|
189
|
+
{ x: x + width, y: y - height },
|
|
190
|
+
{ x: x + inset, y: y - height },
|
|
191
|
+
{ x, y: y - primary }
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return points
|
|
196
|
+
.map(
|
|
197
|
+
(point) =>
|
|
198
|
+
formatNumber(point.x) +
|
|
199
|
+
',' +
|
|
200
|
+
formatNumber(projectSchematicY(sheetHeight, point.y))
|
|
201
|
+
)
|
|
202
|
+
.join(' ')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Builds one harness-entry stub and label.
|
|
207
|
+
* @param {{ x: number, y: number, width: number, height: number }} connector
|
|
208
|
+
* @param {{ name?: string, side?: 'left' | 'right' | 'top' | 'bottom', distanceFromTop?: number, textColor?: string }} entry
|
|
209
|
+
* @param {number} sheetHeight
|
|
210
|
+
* @param {{ fontSize: number, fontFamily: string, fontWeight: number }} textOptions
|
|
211
|
+
* @param {string} connectorStroke
|
|
212
|
+
* @returns {string}
|
|
213
|
+
*/
|
|
214
|
+
static #buildEntryMarkup(
|
|
215
|
+
connector,
|
|
216
|
+
entry,
|
|
217
|
+
sheetHeight,
|
|
218
|
+
textOptions,
|
|
219
|
+
connectorStroke
|
|
220
|
+
) {
|
|
221
|
+
const placement = SchematicHarnessRenderer.#entryPlacement(
|
|
222
|
+
connector,
|
|
223
|
+
entry,
|
|
224
|
+
sheetHeight
|
|
225
|
+
)
|
|
226
|
+
const labelColor = SchematicColorResolver.resolveColor(
|
|
227
|
+
entry.textColor,
|
|
228
|
+
'--schematic-default-ink-color',
|
|
229
|
+
true
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
'<g class="schematic-harness-entry"><line x1="' +
|
|
234
|
+
formatNumber(placement.x1) +
|
|
235
|
+
'" y1="' +
|
|
236
|
+
formatNumber(placement.y1) +
|
|
237
|
+
'" x2="' +
|
|
238
|
+
formatNumber(placement.x2) +
|
|
239
|
+
'" y2="' +
|
|
240
|
+
formatNumber(placement.y2) +
|
|
241
|
+
'" stroke="' +
|
|
242
|
+
escapeHtml(connectorStroke) +
|
|
243
|
+
'" />' +
|
|
244
|
+
createSvgText(
|
|
245
|
+
'schematic-harness-entry-label',
|
|
246
|
+
placement.labelX,
|
|
247
|
+
placement.labelY,
|
|
248
|
+
entry.name || '',
|
|
249
|
+
labelColor,
|
|
250
|
+
placement.anchor,
|
|
251
|
+
textOptions
|
|
252
|
+
) +
|
|
253
|
+
'</g>'
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Resolves the entry stub and text placement for every connector side.
|
|
259
|
+
* @param {{ x: number, y: number, width: number, height: number }} connector
|
|
260
|
+
* @param {{ side?: 'left' | 'right' | 'top' | 'bottom', distanceFromTop?: number }} entry
|
|
261
|
+
* @param {number} sheetHeight
|
|
262
|
+
* @returns {{ x1: number, y1: number, x2: number, y2: number, labelX: number, labelY: number, anchor: 'start' | 'middle' | 'end' }}
|
|
263
|
+
*/
|
|
264
|
+
static #entryPlacement(connector, entry, sheetHeight) {
|
|
265
|
+
const x = Number(connector.x) || 0
|
|
266
|
+
const y = Number(connector.y) || 0
|
|
267
|
+
const width = Number(connector.width) || 0
|
|
268
|
+
const height = Number(connector.height) || 0
|
|
269
|
+
const distance = Number(entry.distanceFromTop) || 0
|
|
270
|
+
const side = entry.side || 'right'
|
|
271
|
+
const baselineLift = 3
|
|
272
|
+
|
|
273
|
+
if (side === 'left') {
|
|
274
|
+
const entryY = projectSchematicY(sheetHeight, y - distance)
|
|
275
|
+
return {
|
|
276
|
+
x1: x,
|
|
277
|
+
y1: entryY,
|
|
278
|
+
x2: x - 10,
|
|
279
|
+
y2: entryY,
|
|
280
|
+
labelX: x - 14,
|
|
281
|
+
labelY: entryY + baselineLift,
|
|
282
|
+
anchor: 'end'
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (side === 'top') {
|
|
286
|
+
const entryX = x + distance
|
|
287
|
+
const entryY = projectSchematicY(sheetHeight, y)
|
|
288
|
+
return {
|
|
289
|
+
x1: entryX,
|
|
290
|
+
y1: entryY,
|
|
291
|
+
x2: entryX,
|
|
292
|
+
y2: entryY - 10,
|
|
293
|
+
labelX: entryX,
|
|
294
|
+
labelY: entryY - 13,
|
|
295
|
+
anchor: 'middle'
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (side === 'bottom') {
|
|
299
|
+
const entryX = x + distance
|
|
300
|
+
const entryY = projectSchematicY(sheetHeight, y - height)
|
|
301
|
+
return {
|
|
302
|
+
x1: entryX,
|
|
303
|
+
y1: entryY,
|
|
304
|
+
x2: entryX,
|
|
305
|
+
y2: entryY + 10,
|
|
306
|
+
labelX: entryX,
|
|
307
|
+
labelY: entryY + 19,
|
|
308
|
+
anchor: 'middle'
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const entryY = projectSchematicY(sheetHeight, y - distance)
|
|
313
|
+
return {
|
|
314
|
+
x1: x + width,
|
|
315
|
+
y1: entryY,
|
|
316
|
+
x2: x + width + 10,
|
|
317
|
+
y2: entryY,
|
|
318
|
+
labelX: x + width + 14,
|
|
319
|
+
labelY: entryY + baselineLift,
|
|
320
|
+
anchor: 'start'
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Builds the connector harness-type label.
|
|
326
|
+
* @param {{ text?: string, x: number, y: number, color?: string }} typeLabel
|
|
327
|
+
* @param {number} sheetHeight
|
|
328
|
+
* @param {{ fontSize: number, fontFamily: string, fontWeight: number }} textOptions
|
|
329
|
+
* @returns {string}
|
|
330
|
+
*/
|
|
331
|
+
static #buildTypeLabelMarkup(typeLabel, sheetHeight, textOptions) {
|
|
332
|
+
return createSvgText(
|
|
333
|
+
'schematic-harness-type',
|
|
334
|
+
typeLabel.x,
|
|
335
|
+
projectSchematicY(sheetHeight, typeLabel.y),
|
|
336
|
+
typeLabel.text || '',
|
|
337
|
+
SchematicColorResolver.resolveColor(
|
|
338
|
+
typeLabel.color,
|
|
339
|
+
'--schematic-default-ink-color',
|
|
340
|
+
true
|
|
341
|
+
),
|
|
342
|
+
'start',
|
|
343
|
+
textOptions
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Places vertical owner annotations in columns clear of narrow symbol bodies.
|
|
7
|
+
*/
|
|
8
|
+
export class SchematicRotatedOwnerTextPlacement {
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the horizontal baseline for one rotated owner annotation.
|
|
11
|
+
* @param {{ ownerIndex?: string, recordType?: string, rotation?: number }} text
|
|
12
|
+
* @param {number} sourceX
|
|
13
|
+
* @param {number} fontSize
|
|
14
|
+
* @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerBodyBounds
|
|
15
|
+
* @returns {number}
|
|
16
|
+
*/
|
|
17
|
+
static resolveX(text, sourceX, fontSize, ownerBodyBounds) {
|
|
18
|
+
const numericX = Number(sourceX)
|
|
19
|
+
const numericFontSize = Number(fontSize)
|
|
20
|
+
const ownerIndex = String(text?.ownerIndex || '').trim()
|
|
21
|
+
const rotation = SchematicRotatedOwnerTextPlacement.#normalizeDegrees(
|
|
22
|
+
text?.rotation
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if (
|
|
26
|
+
text?.recordType !== '41' ||
|
|
27
|
+
!ownerIndex ||
|
|
28
|
+
(rotation !== 90 && rotation !== 270) ||
|
|
29
|
+
!Number.isFinite(numericX) ||
|
|
30
|
+
!Number.isFinite(numericFontSize) ||
|
|
31
|
+
numericFontSize <= 0
|
|
32
|
+
) {
|
|
33
|
+
return numericX
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const bounds = ownerBodyBounds.get(ownerIndex)
|
|
37
|
+
if (!bounds || numericX < Number(bounds.maxX)) {
|
|
38
|
+
return numericX
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return numericX + numericFontSize
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Normalizes a source angle to the positive 0-359 degree range.
|
|
46
|
+
* @param {number | undefined} value
|
|
47
|
+
* @returns {number}
|
|
48
|
+
*/
|
|
49
|
+
static #normalizeDegrees(value) {
|
|
50
|
+
const numericValue = Number(value)
|
|
51
|
+
if (!Number.isFinite(numericValue)) return 0
|
|
52
|
+
|
|
53
|
+
return ((numericValue % 360) + 360) % 360
|
|
54
|
+
}
|
|
55
|
+
}
|