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