altium-toolkit 0.1.16 → 0.1.18
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/README.md +3 -2
- package/package.json +1 -1
- package/src/core/altium/AsciiRecordParser.mjs +111 -2
- package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +13 -4
- package/src/core/altium/PcbLibStreamExtractor.mjs +1 -1
- package/src/core/altium/PcbModelParser.mjs +1 -1
- package/src/core/altium/PcbRawRecordRegistry.mjs +1 -1
- package/src/core/altium/PcbViaPrimitiveParser.mjs +1 -1
- package/src/core/altium/SchematicPinParser.mjs +20 -4
- package/src/core/altium/SchematicTextParser.mjs +14 -6
- package/src/styles/altium-renderers.css +6 -3
- package/src/ui/PcbSvgRenderer.mjs +7 -4
- package/src/ui/SchematicContentLayout.mjs +117 -9
- package/src/ui/SchematicShapeRenderer.mjs +14 -5
- package/src/ui/SchematicSvgRenderer.mjs +12 -6
package/README.md
CHANGED
|
@@ -12,8 +12,9 @@ outputs from the recovered model.
|
|
|
12
12
|
|
|
13
13
|
The package was extracted from [ECAD Forge](https://ecadforge.app/), where it
|
|
14
14
|
is used for browser-based Altium document parsing and deterministic render
|
|
15
|
-
output.
|
|
16
|
-
|
|
15
|
+
output. It is also used in [PCB Styler](https://pcb-styler.app/). Its parser
|
|
16
|
+
behavior, normalized model shape, and renderer output can be reused by other
|
|
17
|
+
browser or Node-based tools.
|
|
17
18
|
|
|
18
19
|
## Features
|
|
19
20
|
|
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ export class AsciiRecordParser {
|
|
|
16
16
|
static parse(arrayBuffer) {
|
|
17
17
|
const runs = PrintableTextDecoder.extractRunBytes(arrayBuffer)
|
|
18
18
|
const records = []
|
|
19
|
+
let pendingPrefix = ''
|
|
19
20
|
|
|
20
21
|
for (const runBytes of runs) {
|
|
21
22
|
const run = AsciiRecordParser.#bytesToBinaryString(runBytes)
|
|
@@ -26,10 +27,30 @@ export class AsciiRecordParser {
|
|
|
26
27
|
for (const chunk of chunks) {
|
|
27
28
|
const candidate = chunk.trim()
|
|
28
29
|
if (!AsciiRecordParser.#isRecordCandidate(candidate)) continue
|
|
29
|
-
|
|
30
|
+
|
|
31
|
+
const headerPrefix =
|
|
32
|
+
AsciiRecordParser.#extractHeaderFieldPrefix(candidate)
|
|
33
|
+
if (headerPrefix) {
|
|
34
|
+
pendingPrefix += headerPrefix
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!AsciiRecordParser.#hasRecordMarker(candidate)) {
|
|
39
|
+
pendingPrefix += candidate
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
records.push(
|
|
44
|
+
AsciiRecordParser.#parseRecord(pendingPrefix + candidate)
|
|
45
|
+
)
|
|
46
|
+
pendingPrefix = ''
|
|
30
47
|
}
|
|
31
48
|
}
|
|
32
49
|
|
|
50
|
+
if (pendingPrefix) {
|
|
51
|
+
records.push(AsciiRecordParser.#parseRecord(pendingPrefix))
|
|
52
|
+
}
|
|
53
|
+
|
|
33
54
|
return records
|
|
34
55
|
}
|
|
35
56
|
|
|
@@ -44,6 +65,40 @@ export class AsciiRecordParser {
|
|
|
44
65
|
return candidate.split('|').length >= 4
|
|
45
66
|
}
|
|
46
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Returns true when a printable fragment contains its marker field.
|
|
70
|
+
* @param {string} candidate
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
static #hasRecordMarker(candidate) {
|
|
74
|
+
return /(?:^|\|)(?:HEADER|RECORD|UNICODE|SELECTION|KIND)=/.test(
|
|
75
|
+
candidate
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extracts schematic sheet fields that trail a schematic header before the
|
|
81
|
+
* first record.
|
|
82
|
+
* @param {string} candidate
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
static #extractHeaderFieldPrefix(candidate) {
|
|
86
|
+
if (!candidate.startsWith('|HEADER=')) {
|
|
87
|
+
return ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const segments = candidate.split('|').filter(Boolean)
|
|
91
|
+
if (segments.length <= 1) {
|
|
92
|
+
return ''
|
|
93
|
+
}
|
|
94
|
+
const headerValue = segments[0].slice('HEADER='.length)
|
|
95
|
+
if (!/^Schematic Document$/i.test(headerValue)) {
|
|
96
|
+
return ''
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return '|' + segments.slice(1).join('|')
|
|
100
|
+
}
|
|
101
|
+
|
|
47
102
|
/**
|
|
48
103
|
* Parses one pipe-delimited record into a field object.
|
|
49
104
|
* @param {string} raw
|
|
@@ -89,7 +144,61 @@ export class AsciiRecordParser {
|
|
|
89
144
|
AsciiRecordParser.#appendFieldValue(fields, key, value)
|
|
90
145
|
}
|
|
91
146
|
|
|
92
|
-
return {
|
|
147
|
+
return {
|
|
148
|
+
raw,
|
|
149
|
+
fields: AsciiRecordParser.#createCaseInsensitiveFields(fields)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Wraps parsed fields so consumers can read native records regardless of
|
|
155
|
+
* whether the printable stream used upper, lower, or mixed-case keys.
|
|
156
|
+
* @param {Record<string, string | string[]>} fields
|
|
157
|
+
* @returns {Record<string, string | string[]>}
|
|
158
|
+
*/
|
|
159
|
+
static #createCaseInsensitiveFields(fields) {
|
|
160
|
+
const normalizedKeyIndex =
|
|
161
|
+
AsciiRecordParser.#buildCaseInsensitiveFieldIndex(fields)
|
|
162
|
+
|
|
163
|
+
return new Proxy(fields, {
|
|
164
|
+
get(target, property, receiver) {
|
|
165
|
+
if (typeof property !== 'string' || property in target) {
|
|
166
|
+
return Reflect.get(target, property, receiver)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const normalizedKey = normalizedKeyIndex.get(
|
|
170
|
+
property.toLowerCase()
|
|
171
|
+
)
|
|
172
|
+
return normalizedKey
|
|
173
|
+
? Reflect.get(target, normalizedKey, receiver)
|
|
174
|
+
: undefined
|
|
175
|
+
},
|
|
176
|
+
has(target, property) {
|
|
177
|
+
if (typeof property !== 'string' || property in target) {
|
|
178
|
+
return Reflect.has(target, property)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return normalizedKeyIndex.has(property.toLowerCase())
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Builds a lookup from lower-case field names to their source key.
|
|
188
|
+
* @param {Record<string, string | string[]>} fields
|
|
189
|
+
* @returns {Map<string, string>}
|
|
190
|
+
*/
|
|
191
|
+
static #buildCaseInsensitiveFieldIndex(fields) {
|
|
192
|
+
const normalizedKeyIndex = new Map()
|
|
193
|
+
|
|
194
|
+
for (const key of Object.keys(fields)) {
|
|
195
|
+
const normalizedKey = key.toLowerCase()
|
|
196
|
+
if (!normalizedKeyIndex.has(normalizedKey)) {
|
|
197
|
+
normalizedKeyIndex.set(normalizedKey, key)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return normalizedKeyIndex
|
|
93
202
|
}
|
|
94
203
|
|
|
95
204
|
/**
|
|
@@ -112,12 +112,21 @@ export class PcbComponentAnnotationNormalizer {
|
|
|
112
112
|
* @returns {number | null}
|
|
113
113
|
*/
|
|
114
114
|
static #textComponentIndex(text) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
if (
|
|
116
|
+
text?.componentIndex !== null &&
|
|
117
|
+
text?.componentIndex !== undefined
|
|
118
|
+
) {
|
|
119
|
+
const componentIndex = Number(text.componentIndex)
|
|
120
|
+
if (Number.isInteger(componentIndex)) {
|
|
121
|
+
return componentIndex
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (text?.ownerIndex === null || text?.ownerIndex === undefined) {
|
|
126
|
+
return null
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
const ownerIndex = Number(text
|
|
129
|
+
const ownerIndex = Number(text.ownerIndex)
|
|
121
130
|
return Number.isInteger(ownerIndex) ? ownerIndex : null
|
|
122
131
|
}
|
|
123
132
|
|
|
@@ -367,7 +367,7 @@ export class PcbModelParser {
|
|
|
367
367
|
summary: {
|
|
368
368
|
title: stripExtension(fileName),
|
|
369
369
|
componentCount: componentRecords.length,
|
|
370
|
-
layerCount: layers.length,
|
|
370
|
+
layerCount: layers.length || primitiveLayers.length,
|
|
371
371
|
outlineSegmentCount: boardOutline.segments.length,
|
|
372
372
|
bomRowCount: bom.length,
|
|
373
373
|
netCount: nets.length,
|
|
@@ -422,8 +422,7 @@ export class SchematicPinParser {
|
|
|
422
422
|
}
|
|
423
423
|
|
|
424
424
|
const segments = []
|
|
425
|
-
const lineStyle =
|
|
426
|
-
ParserUtils.parseNumericField(fields, 'LineStyle') || 0
|
|
425
|
+
const lineStyle = SchematicPinParser.#resolveSchematicLineStyle(fields)
|
|
427
426
|
|
|
428
427
|
for (let index = 1; index < points.length; index += 1) {
|
|
429
428
|
const previous = points[index - 1]
|
|
@@ -477,8 +476,7 @@ export class SchematicPinParser {
|
|
|
477
476
|
}
|
|
478
477
|
|
|
479
478
|
const segments = []
|
|
480
|
-
const lineStyle =
|
|
481
|
-
ParserUtils.parseNumericField(fields, 'LineStyle') || 0
|
|
479
|
+
const lineStyle = SchematicPinParser.#resolveSchematicLineStyle(fields)
|
|
482
480
|
|
|
483
481
|
for (let index = 1; index < points.length; index += 1) {
|
|
484
482
|
const previous = points[index - 1]
|
|
@@ -511,6 +509,24 @@ export class SchematicPinParser {
|
|
|
511
509
|
return segments
|
|
512
510
|
}
|
|
513
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Resolves Altium's legacy and extended schematic line style fields.
|
|
514
|
+
* @param {Record<string, string | string[]>} fields
|
|
515
|
+
* @returns {number}
|
|
516
|
+
*/
|
|
517
|
+
static #resolveSchematicLineStyle(fields) {
|
|
518
|
+
const extendedStyle = ParserUtils.parseNumericField(
|
|
519
|
+
fields,
|
|
520
|
+
'LineStyleExt'
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
if (extendedStyle !== null) {
|
|
524
|
+
return extendedStyle
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return ParserUtils.parseNumericField(fields, 'LineStyle') || 0
|
|
528
|
+
}
|
|
529
|
+
|
|
514
530
|
/**
|
|
515
531
|
* Deduces the visible pins for one schematic symbol owner.
|
|
516
532
|
* @param {{ x: number, y: number, length: number, conglomerate?: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, ownerIndex: string }[]} pins
|
|
@@ -60,7 +60,7 @@ export class SchematicTextParser {
|
|
|
60
60
|
* Normalizes one schematic text record into a drawable text node.
|
|
61
61
|
* @param {Record<string, string | string[]>} fields
|
|
62
62
|
* @param {Record<string, string>} metadata
|
|
63
|
-
* @param {{ width: number, marginWidth: number }} sheet
|
|
63
|
+
* @param {{ width: number, marginWidth: number, titleBlockOn?: boolean }} sheet
|
|
64
64
|
* @param {Record<string, { size: number, family: string, bold: boolean, rotation: number }>} fonts
|
|
65
65
|
* @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] } | null}
|
|
66
66
|
*/
|
|
@@ -431,14 +431,13 @@ export class SchematicTextParser {
|
|
|
431
431
|
* @param {string} name
|
|
432
432
|
* @param {string} rawText
|
|
433
433
|
* @param {string} text
|
|
434
|
-
* @param {{ width: number, marginWidth: number }} sheet
|
|
434
|
+
* @param {{ width: number, marginWidth: number, titleBlockOn?: boolean }} sheet
|
|
435
435
|
* @returns {boolean}
|
|
436
436
|
*/
|
|
437
437
|
static #shouldSkipSchematicText(fields, name, rawText, text, sheet) {
|
|
438
438
|
const normalizedName = String(name || '')
|
|
439
439
|
.trim()
|
|
440
440
|
.toLowerCase()
|
|
441
|
-
const normalizedRawText = String(rawText || '').trim()
|
|
442
441
|
const normalizedText = String(text || '').trim()
|
|
443
442
|
const nonDrawableNames = new Set([
|
|
444
443
|
'kind',
|
|
@@ -448,16 +447,25 @@ export class SchematicTextParser {
|
|
|
448
447
|
'model',
|
|
449
448
|
'part number',
|
|
450
449
|
'pkg type',
|
|
451
|
-
'description'
|
|
450
|
+
'description',
|
|
451
|
+
'vendor',
|
|
452
|
+
'manufacturer',
|
|
453
|
+
'supplier',
|
|
454
|
+
'ic',
|
|
455
|
+
'pinuniqueid',
|
|
456
|
+
'differentialpair'
|
|
452
457
|
])
|
|
453
458
|
|
|
454
459
|
if (nonDrawableNames.has(normalizedName)) return true
|
|
460
|
+
if (/uniqueid$/i.test(normalizedName)) return true
|
|
455
461
|
if (!normalizedText || normalizedText === '*') return true
|
|
456
462
|
if (/^=/.test(normalizedText)) return true
|
|
457
|
-
if (
|
|
463
|
+
if (
|
|
464
|
+
sheet.titleBlockOn &&
|
|
465
|
+
SchematicTextParser.isTitleBlockFooterRecord(fields, sheet.width)
|
|
466
|
+
) {
|
|
458
467
|
return true
|
|
459
468
|
}
|
|
460
|
-
if (/^=/.test(normalizedRawText)) return true
|
|
461
469
|
|
|
462
470
|
return /@designator|initial voltage/i.test(normalizedText)
|
|
463
471
|
}
|
|
@@ -247,11 +247,14 @@
|
|
|
247
247
|
fill: var(--pcb-copper-solid-fill);
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
.pcb-footprint-fill
|
|
251
|
-
.pcb-footprint-region {
|
|
250
|
+
.pcb-footprint-fill {
|
|
252
251
|
fill: var(--pcb-footprint-fill);
|
|
253
252
|
}
|
|
254
253
|
|
|
254
|
+
.pcb-footprint-region {
|
|
255
|
+
fill: var(--pcb-footprint-track-color);
|
|
256
|
+
}
|
|
257
|
+
|
|
255
258
|
.pcb-footprint-track,
|
|
256
259
|
.pcb-footprint-arc {
|
|
257
260
|
stroke: var(--pcb-footprint-track-color);
|
|
@@ -269,7 +272,7 @@
|
|
|
269
272
|
|
|
270
273
|
.pcb-text {
|
|
271
274
|
font-size: 29px;
|
|
272
|
-
fill: var(--pcb-
|
|
275
|
+
fill: var(--pcb-footprint-track-color);
|
|
273
276
|
font-weight: 700;
|
|
274
277
|
font-family: Arial, sans-serif;
|
|
275
278
|
pointer-events: none;
|
|
@@ -40,8 +40,11 @@ export class PcbSvgRenderer {
|
|
|
40
40
|
const vias = pcb.vias || []
|
|
41
41
|
const pads = pcb.pads || []
|
|
42
42
|
const components = pcb.components.slice(0, 260)
|
|
43
|
+
const stackLayers = Array.isArray(pcb.layers) ? pcb.layers : []
|
|
44
|
+
const primitiveLayers = pcb.primitiveLayers || []
|
|
45
|
+
const displayLayers = stackLayers.length ? stackLayers : primitiveLayers
|
|
43
46
|
const texts = PcbTextPrimitiveRenderer.select(
|
|
44
|
-
|
|
47
|
+
primitiveLayers,
|
|
45
48
|
pcb.texts || [],
|
|
46
49
|
'top'
|
|
47
50
|
)
|
|
@@ -54,7 +57,7 @@ export class PcbSvgRenderer {
|
|
|
54
57
|
)
|
|
55
58
|
const footprintPrimitives = PcbEdgeFacingGlyphNormalizer.normalize(
|
|
56
59
|
PcbFootprintPrimitiveSelector.select(
|
|
57
|
-
|
|
60
|
+
primitiveLayers,
|
|
58
61
|
fills,
|
|
59
62
|
tracks,
|
|
60
63
|
arcs,
|
|
@@ -95,7 +98,7 @@ export class PcbSvgRenderer {
|
|
|
95
98
|
vias,
|
|
96
99
|
pads
|
|
97
100
|
)
|
|
98
|
-
const layerMarkup =
|
|
101
|
+
const layerMarkup = displayLayers
|
|
99
102
|
.slice(0, 10)
|
|
100
103
|
.map(
|
|
101
104
|
(layer) =>
|
|
@@ -307,7 +310,7 @@ export class PcbSvgRenderer {
|
|
|
307
310
|
'</h3><p>' +
|
|
308
311
|
components.length +
|
|
309
312
|
' placements, ' +
|
|
310
|
-
|
|
313
|
+
displayLayers.length +
|
|
311
314
|
' layers</p></header>' +
|
|
312
315
|
'<div class="pcb-layout">' +
|
|
313
316
|
'<aside class="pcb-legend"><h4>Board stack</h4><p>Top-facing composite view</p><ul>' +
|
|
@@ -246,27 +246,44 @@ export class SchematicContentLayout {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
const virtualInnerWidth = virtualSourceSheet.width - margin * 2
|
|
249
|
-
const
|
|
249
|
+
const targetScale = (width - margin * 2) / virtualInnerWidth
|
|
250
250
|
|
|
251
|
-
if (!Number.isFinite(
|
|
251
|
+
if (!Number.isFinite(targetScale) || targetScale <= 1) {
|
|
252
252
|
return ''
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
const pivotX = margin
|
|
256
256
|
const pivotY = height - margin
|
|
257
|
+
const topLimit = margin + contentPadding * 0.2
|
|
258
|
+
const bottomLimit = height - margin - footerReserve
|
|
259
|
+
const rightLimit = width - margin
|
|
260
|
+
const scale = Math.min(
|
|
261
|
+
targetScale,
|
|
262
|
+
...SchematicContentLayout.#resolvePivotScaleLimits(
|
|
263
|
+
bounds,
|
|
264
|
+
pivotX,
|
|
265
|
+
pivotY,
|
|
266
|
+
margin,
|
|
267
|
+
topLimit,
|
|
268
|
+
rightLimit,
|
|
269
|
+
bottomLimit
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if (!Number.isFinite(scale) || scale <= 1) {
|
|
274
|
+
return ''
|
|
275
|
+
}
|
|
276
|
+
|
|
257
277
|
const projectedMinX = pivotX + (bounds.minX - pivotX) * scale
|
|
258
278
|
const projectedMaxX = pivotX + (bounds.maxX - pivotX) * scale
|
|
259
279
|
const projectedMinY = pivotY + (bounds.minY - pivotY) * scale
|
|
260
280
|
const projectedMaxY = pivotY + (bounds.maxY - pivotY) * scale
|
|
261
|
-
const topLimit = margin + contentPadding
|
|
262
|
-
const bottomLimit = height - margin - footerReserve
|
|
263
|
-
const rightLimit = width - margin
|
|
264
281
|
|
|
265
282
|
if (
|
|
266
|
-
projectedMinX < margin ||
|
|
267
|
-
projectedMaxX > rightLimit ||
|
|
268
|
-
projectedMinY < topLimit ||
|
|
269
|
-
projectedMaxY > bottomLimit
|
|
283
|
+
projectedMinX < margin - 0.01 ||
|
|
284
|
+
projectedMaxX > rightLimit + 0.01 ||
|
|
285
|
+
projectedMinY < topLimit - 0.01 ||
|
|
286
|
+
projectedMaxY > bottomLimit + 0.01
|
|
270
287
|
) {
|
|
271
288
|
return ''
|
|
272
289
|
}
|
|
@@ -286,6 +303,74 @@ export class SchematicContentLayout {
|
|
|
286
303
|
)
|
|
287
304
|
}
|
|
288
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Resolves per-edge maximum scale factors for a bottom-left pivot.
|
|
308
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
309
|
+
* @param {number} pivotX
|
|
310
|
+
* @param {number} pivotY
|
|
311
|
+
* @param {number} leftLimit
|
|
312
|
+
* @param {number} topLimit
|
|
313
|
+
* @param {number} rightLimit
|
|
314
|
+
* @param {number} bottomLimit
|
|
315
|
+
* @returns {number[]}
|
|
316
|
+
*/
|
|
317
|
+
static #resolvePivotScaleLimits(
|
|
318
|
+
bounds,
|
|
319
|
+
pivotX,
|
|
320
|
+
pivotY,
|
|
321
|
+
leftLimit,
|
|
322
|
+
topLimit,
|
|
323
|
+
rightLimit,
|
|
324
|
+
bottomLimit
|
|
325
|
+
) {
|
|
326
|
+
return [
|
|
327
|
+
SchematicContentLayout.#resolvePivotScaleLimit(
|
|
328
|
+
pivotX,
|
|
329
|
+
bounds.minX,
|
|
330
|
+
leftLimit,
|
|
331
|
+
'min'
|
|
332
|
+
),
|
|
333
|
+
SchematicContentLayout.#resolvePivotScaleLimit(
|
|
334
|
+
pivotX,
|
|
335
|
+
bounds.maxX,
|
|
336
|
+
rightLimit,
|
|
337
|
+
'max'
|
|
338
|
+
),
|
|
339
|
+
SchematicContentLayout.#resolvePivotScaleLimit(
|
|
340
|
+
pivotY,
|
|
341
|
+
bounds.minY,
|
|
342
|
+
topLimit,
|
|
343
|
+
'min'
|
|
344
|
+
),
|
|
345
|
+
SchematicContentLayout.#resolvePivotScaleLimit(
|
|
346
|
+
pivotY,
|
|
347
|
+
bounds.maxY,
|
|
348
|
+
bottomLimit,
|
|
349
|
+
'max'
|
|
350
|
+
)
|
|
351
|
+
]
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Resolves one axis scale cap from an edge limit.
|
|
356
|
+
* @param {number} pivot
|
|
357
|
+
* @param {number} coordinate
|
|
358
|
+
* @param {number} limit
|
|
359
|
+
* @param {'min' | 'max'} mode
|
|
360
|
+
* @returns {number}
|
|
361
|
+
*/
|
|
362
|
+
static #resolvePivotScaleLimit(pivot, coordinate, limit, mode) {
|
|
363
|
+
if (mode === 'min') {
|
|
364
|
+
return coordinate < pivot
|
|
365
|
+
? (pivot - limit) / (pivot - coordinate)
|
|
366
|
+
: Infinity
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return coordinate > pivot
|
|
370
|
+
? (limit - pivot) / (coordinate - pivot)
|
|
371
|
+
: Infinity
|
|
372
|
+
}
|
|
373
|
+
|
|
289
374
|
/**
|
|
290
375
|
* Resolves the maximum sheet-wide scale implied by source and normalized
|
|
291
376
|
* page sizes.
|
|
@@ -538,6 +623,10 @@ export class SchematicContentLayout {
|
|
|
538
623
|
}
|
|
539
624
|
|
|
540
625
|
for (const component of schematic?.components || []) {
|
|
626
|
+
if (!SchematicContentLayout.#isDrawableComponentAnchor(component)) {
|
|
627
|
+
continue
|
|
628
|
+
}
|
|
629
|
+
|
|
541
630
|
coordinates.push([
|
|
542
631
|
component.x,
|
|
543
632
|
projectSchematicY(sheetHeight, component.y)
|
|
@@ -604,6 +693,25 @@ export class SchematicContentLayout {
|
|
|
604
693
|
}
|
|
605
694
|
}
|
|
606
695
|
|
|
696
|
+
/**
|
|
697
|
+
* Returns true when one component anchor can produce visible fallback
|
|
698
|
+
* markup and should therefore influence layout bounds.
|
|
699
|
+
* @param {{ x?: number, y?: number, designator?: string }} component
|
|
700
|
+
* @returns {boolean}
|
|
701
|
+
*/
|
|
702
|
+
static #isDrawableComponentAnchor(component) {
|
|
703
|
+
if (!component) return false
|
|
704
|
+
|
|
705
|
+
const hasCoordinates =
|
|
706
|
+
Number.isFinite(component.x) &&
|
|
707
|
+
Number.isFinite(component.y) &&
|
|
708
|
+
(component.x !== 0 || component.y !== 0)
|
|
709
|
+
const hasDesignator =
|
|
710
|
+
Boolean(component.designator) && component.designator !== 'U?'
|
|
711
|
+
|
|
712
|
+
return hasCoordinates && hasDesignator
|
|
713
|
+
}
|
|
714
|
+
|
|
607
715
|
/**
|
|
608
716
|
* Projects authored rectangles or regions into rendered SVG bounds.
|
|
609
717
|
* @param {{ x: number, y: number, width: number, height: number }[] | undefined} boxes
|
|
@@ -231,18 +231,27 @@ export class SchematicShapeRenderer {
|
|
|
231
231
|
* @returns {string}
|
|
232
232
|
*/
|
|
233
233
|
static #buildSchematicStrokeStyleAttributes(lineWidth, lineStyle) {
|
|
234
|
-
|
|
234
|
+
const resolvedLineStyle = Number(lineStyle || 0)
|
|
235
|
+
if (
|
|
236
|
+
resolvedLineStyle !== 1 &&
|
|
237
|
+
resolvedLineStyle !== 2 &&
|
|
238
|
+
resolvedLineStyle !== 3
|
|
239
|
+
)
|
|
235
240
|
return ''
|
|
236
|
-
}
|
|
237
241
|
|
|
238
242
|
const dashLength = Math.max(Number(lineWidth || 1) * 8, 8)
|
|
239
243
|
const gapLength = Math.max(Number(lineWidth || 1) * 5, 5)
|
|
244
|
+
const dotLength = Math.max(Number(lineWidth || 1) * 1.5, 1.5)
|
|
245
|
+
const dashPattern =
|
|
246
|
+
resolvedLineStyle === 1
|
|
247
|
+
? [dashLength, gapLength]
|
|
248
|
+
: resolvedLineStyle === 2
|
|
249
|
+
? [dotLength, gapLength]
|
|
250
|
+
: [dashLength, gapLength, dotLength, gapLength]
|
|
240
251
|
|
|
241
252
|
return (
|
|
242
253
|
' stroke-dasharray="' +
|
|
243
|
-
formatNumber(
|
|
244
|
-
' ' +
|
|
245
|
-
formatNumber(gapLength) +
|
|
254
|
+
dashPattern.map((part) => formatNumber(part)).join(' ') +
|
|
246
255
|
'" stroke-linecap="round"'
|
|
247
256
|
)
|
|
248
257
|
}
|
|
@@ -511,16 +511,22 @@ export class SchematicSvgRenderer {
|
|
|
511
511
|
* @returns {string}
|
|
512
512
|
*/
|
|
513
513
|
static #buildSchematicLineStyleAttributes(line) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
514
|
+
const lineStyle = Number(line.lineStyle || 0)
|
|
515
|
+
if (lineStyle !== 1 && lineStyle !== 2 && lineStyle !== 3) return ''
|
|
516
|
+
|
|
517
517
|
const dashLength = Math.max(Number(line.width || 1) * 8, 8)
|
|
518
518
|
const gapLength = Math.max(Number(line.width || 1) * 5, 5)
|
|
519
|
+
const dotLength = Math.max(Number(line.width || 1) * 1.5, 1.5)
|
|
520
|
+
const dashPattern =
|
|
521
|
+
lineStyle === 1
|
|
522
|
+
? [dashLength, gapLength]
|
|
523
|
+
: lineStyle === 2
|
|
524
|
+
? [dotLength, gapLength]
|
|
525
|
+
: [dashLength, gapLength, dotLength, gapLength]
|
|
526
|
+
|
|
519
527
|
return (
|
|
520
528
|
' stroke-dasharray="' +
|
|
521
|
-
formatNumber(
|
|
522
|
-
' ' +
|
|
523
|
-
formatNumber(gapLength) +
|
|
529
|
+
dashPattern.map((part) => formatNumber(part)).join(' ') +
|
|
524
530
|
'" stroke-linecap="round"'
|
|
525
531
|
)
|
|
526
532
|
}
|