altium-toolkit 0.1.0
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/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +20 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +22 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/GPL-3.0-or-later.txt +232 -0
- package/NOTICE.md +32 -0
- package/README.md +116 -0
- package/docs/api.md +73 -0
- package/docs/model-format.md +36 -0
- package/docs/testing.md +25 -0
- package/examples/README.md +47 -0
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
- package/examples/arduino-uno/SvgViewportController.mjs +306 -0
- package/examples/arduino-uno/example.mjs +480 -0
- package/examples/arduino-uno/index.html +163 -0
- package/examples/arduino-uno/styles.css +552 -0
- package/examples/server.mjs +212 -0
- package/package.json +53 -0
- package/spec/library-scope.md +32 -0
- package/src/core/BinaryReader.mjs +127 -0
- package/src/core/altium/AltiumLayoutParser.mjs +485 -0
- package/src/core/altium/AltiumParser.mjs +1007 -0
- package/src/core/altium/AsciiRecordParser.mjs +151 -0
- package/src/core/altium/ParserUtils.mjs +173 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
- package/src/core/altium/PcbModelParser.mjs +336 -0
- package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
- package/src/core/altium/PcbStreamExtractor.mjs +210 -0
- package/src/core/altium/PrintableTextDecoder.mjs +156 -0
- package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
- package/src/core/altium/SchematicImageParser.mjs +173 -0
- package/src/core/altium/SchematicJunctionParser.mjs +43 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
- package/src/core/altium/SchematicPinParser.mjs +767 -0
- package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
- package/src/core/altium/SchematicSheetParser.mjs +241 -0
- package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
- package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
- package/src/core/altium/SchematicTextParser.mjs +708 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
- package/src/core/ole/OleCompoundDocument.mjs +439 -0
- package/src/core/ole/OleConstants.mjs +64 -0
- package/src/core/ole/OleDirectoryEntry.mjs +95 -0
- package/src/index.mjs +7 -0
- package/src/parser.mjs +21 -0
- package/src/renderers.mjs +15 -0
- package/src/scene3d.mjs +9 -0
- package/src/styles/altium-renderers.css +358 -0
- package/src/ui/BomTableRenderer.mjs +46 -0
- package/src/ui/PcbArcUtils.mjs +189 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
- package/src/ui/PcbScene3dBuilder.mjs +742 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
- package/src/ui/PcbScene3dPackages.mjs +137 -0
- package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
- package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
- package/src/ui/PcbSvgRenderer.mjs +906 -0
- package/src/ui/SchematicColorResolver.mjs +132 -0
- package/src/ui/SchematicContentLayout.mjs +661 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
- package/src/ui/SchematicImageRenderer.mjs +135 -0
- package/src/ui/SchematicJunctionRenderer.mjs +381 -0
- package/src/ui/SchematicNoteRenderer.mjs +427 -0
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
- package/src/ui/SchematicPortRenderer.mjs +558 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
- package/src/ui/SchematicRegionRenderer.mjs +94 -0
- package/src/ui/SchematicShapeRenderer.mjs +398 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
- package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
- package/src/ui/SchematicSvgRenderer.mjs +756 -0
- package/src/ui/SchematicSvgUtils.mjs +182 -0
- package/src/ui/SchematicTypography.mjs +204 -0
- package/src/workers/altium-parser.worker.mjs +29 -0
|
@@ -0,0 +1,661 @@
|
|
|
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
|
+
|
|
8
|
+
const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
|
|
9
|
+
const RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO = 0.35
|
|
10
|
+
const ISO_A_PORTRAIT_SHEETS = [
|
|
11
|
+
{ label: 'A5', width: 583, height: 827 },
|
|
12
|
+
{ label: 'A4', width: 827, height: 1169 },
|
|
13
|
+
{ label: 'A3', width: 1169, height: 1654 },
|
|
14
|
+
{ label: 'A2', width: 1654, height: 2339 },
|
|
15
|
+
{ label: 'A1', width: 2339, height: 3307 },
|
|
16
|
+
{ label: 'A0', width: 3307, height: 4681 }
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Computes schematic content transforms and clipping for normalized pages.
|
|
21
|
+
*/
|
|
22
|
+
export class SchematicContentLayout {
|
|
23
|
+
/**
|
|
24
|
+
* Builds one deterministic clip-path identifier for one schematic SVG.
|
|
25
|
+
* @param {number} width
|
|
26
|
+
* @param {number} height
|
|
27
|
+
* @param {{ sheet?: { marginWidth?: number }, lines?: unknown[], texts?: unknown[], components?: unknown[], pins?: unknown[], regions?: unknown[] }} schematic
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
static buildClipId(width, height, schematic) {
|
|
31
|
+
return [
|
|
32
|
+
'schematic-content-clip',
|
|
33
|
+
Math.round(Number(width || 0)),
|
|
34
|
+
Math.round(Number(height || 0)),
|
|
35
|
+
Math.round(Number(schematic?.sheet?.marginWidth || 20)),
|
|
36
|
+
(schematic?.lines || []).length,
|
|
37
|
+
(schematic?.texts || []).length,
|
|
38
|
+
(schematic?.components || []).length,
|
|
39
|
+
(schematic?.pins || []).length,
|
|
40
|
+
(schematic?.regions || []).length
|
|
41
|
+
].join('-')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Builds the clip-path that confines schematic primitives to the sheet
|
|
46
|
+
* inner frame.
|
|
47
|
+
* @param {number} width
|
|
48
|
+
* @param {number} height
|
|
49
|
+
* @param {{ sheet?: { marginWidth?: number } }} schematic
|
|
50
|
+
* @param {string} clipId
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
static buildClipMarkup(width, height, schematic, clipId) {
|
|
54
|
+
const margin = Math.max(Number(schematic?.sheet?.marginWidth || 20), 10)
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
'<defs><clipPath id="' +
|
|
58
|
+
escapeHtml(clipId) +
|
|
59
|
+
'"><rect x="' +
|
|
60
|
+
formatNumber(margin) +
|
|
61
|
+
'" y="' +
|
|
62
|
+
formatNumber(margin) +
|
|
63
|
+
'" width="' +
|
|
64
|
+
formatNumber(Math.max(width - margin * 2, 10)) +
|
|
65
|
+
'" height="' +
|
|
66
|
+
formatNumber(Math.max(height - margin * 2, 10)) +
|
|
67
|
+
'" /></clipPath></defs>'
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Builds one uniform SVG transform that scales recovered schematic
|
|
73
|
+
* primitives from their source inner frame into a larger normalized page.
|
|
74
|
+
* @param {number} width
|
|
75
|
+
* @param {number} height
|
|
76
|
+
* @param {{ sheet?: { marginWidth?: number, sourceWidth?: number, sourceHeight?: number, fonts?: Record<string, { size?: number }> }, lines?: { x1: number, y1: number, x2: number, y2: number }[], polygons?: { points: { x: number, y: number }[] }[], rectangles?: { x: number, y: number, width: number, height: number }[], regions?: { x: number, y: number, width: number, height: number }[], ellipses?: { x: number, y: number, radiusX: number, radiusY: number }[], arcs?: { x: number, y: number, radius: number }[], texts?: { x: number, y: number }[], components?: { x: number, y: number }[], pins?: { x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[], ports?: { x: number, y: number, width: number, height: number, direction?: 'left' | 'right' | 'up' | 'down' }[], crosses?: { x: number, y: number, size?: number }[] }} schematic
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
static buildTransform(width, height, schematic) {
|
|
80
|
+
const sheet = schematic?.sheet
|
|
81
|
+
const margin = Math.max(Number(sheet?.marginWidth || 20), 10)
|
|
82
|
+
const bounds = SchematicContentLayout.#collectRenderedContentBounds(
|
|
83
|
+
schematic,
|
|
84
|
+
height
|
|
85
|
+
)
|
|
86
|
+
const contentPadding = SchematicContentLayout.#resolveContentPadding(
|
|
87
|
+
sheet,
|
|
88
|
+
margin
|
|
89
|
+
)
|
|
90
|
+
const footerReserve = SchematicContentLayout.#resolveFooterReserve(
|
|
91
|
+
sheet,
|
|
92
|
+
margin
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if (!bounds) {
|
|
96
|
+
return ''
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const normalizedTransform =
|
|
100
|
+
SchematicContentLayout.#buildNormalizedSheetTransform(
|
|
101
|
+
width,
|
|
102
|
+
height,
|
|
103
|
+
schematic,
|
|
104
|
+
sheet,
|
|
105
|
+
margin,
|
|
106
|
+
bounds,
|
|
107
|
+
contentPadding,
|
|
108
|
+
footerReserve
|
|
109
|
+
)
|
|
110
|
+
if (normalizedTransform) {
|
|
111
|
+
return normalizedTransform
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return SchematicContentLayout.#buildSparseCustomSheetTransform(
|
|
115
|
+
width,
|
|
116
|
+
height,
|
|
117
|
+
sheet,
|
|
118
|
+
margin,
|
|
119
|
+
bounds,
|
|
120
|
+
contentPadding,
|
|
121
|
+
footerReserve
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Builds the existing normalized-sheet transform for pages that have been
|
|
127
|
+
* expanded beyond their original source size.
|
|
128
|
+
* @param {number} width
|
|
129
|
+
* @param {number} height
|
|
130
|
+
* @param {{ rectangles?: { x: number, y: number, width: number, height: number }[], regions?: { x: number, y: number, width: number, height: number }[] }} schematic
|
|
131
|
+
* @param {{ sourceWidth?: number, sourceHeight?: number } | undefined} sheet
|
|
132
|
+
* @param {number} margin
|
|
133
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
134
|
+
* @param {number} contentPadding
|
|
135
|
+
* @param {number} footerReserve
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
static #buildNormalizedSheetTransform(
|
|
139
|
+
width,
|
|
140
|
+
height,
|
|
141
|
+
schematic,
|
|
142
|
+
sheet,
|
|
143
|
+
margin,
|
|
144
|
+
bounds,
|
|
145
|
+
contentPadding,
|
|
146
|
+
footerReserve
|
|
147
|
+
) {
|
|
148
|
+
const normalizedScaleLimit =
|
|
149
|
+
SchematicContentLayout.#buildNormalizedSheetScaleLimit(
|
|
150
|
+
width,
|
|
151
|
+
height,
|
|
152
|
+
sheet,
|
|
153
|
+
margin
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if (!normalizedScaleLimit) {
|
|
157
|
+
return ''
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const usedWidth = bounds.maxX - bounds.minX
|
|
161
|
+
const usedHeight = bounds.maxY - bounds.minY
|
|
162
|
+
|
|
163
|
+
if (usedWidth <= 0 || usedHeight <= 0) {
|
|
164
|
+
return ''
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const topLimit = margin + contentPadding * 0.2
|
|
168
|
+
const bottomLimit = height - margin - footerReserve
|
|
169
|
+
const dominantAnchorBounds =
|
|
170
|
+
SchematicContentLayout.#resolveNormalizedAnchorBounds(
|
|
171
|
+
schematic,
|
|
172
|
+
height,
|
|
173
|
+
bounds
|
|
174
|
+
)
|
|
175
|
+
const anchorDeltaY = dominantAnchorBounds.minY - bounds.minY
|
|
176
|
+
const rightFitScaleLimit =
|
|
177
|
+
(width - margin - contentPadding - bounds.minX) / usedWidth
|
|
178
|
+
const bottomFitScaleLimit =
|
|
179
|
+
(bottomLimit - topLimit) / (bounds.maxY - dominantAnchorBounds.minY)
|
|
180
|
+
const scale = Math.min(
|
|
181
|
+
normalizedScaleLimit,
|
|
182
|
+
rightFitScaleLimit,
|
|
183
|
+
bottomFitScaleLimit
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if (!Number.isFinite(scale) || scale <= 1) {
|
|
187
|
+
return ''
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const targetMinY = topLimit - anchorDeltaY * scale
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
' transform="translate(' +
|
|
194
|
+
formatNumber(bounds.minX) +
|
|
195
|
+
' ' +
|
|
196
|
+
formatNumber(targetMinY) +
|
|
197
|
+
') scale(' +
|
|
198
|
+
formatNumber(scale) +
|
|
199
|
+
') translate(' +
|
|
200
|
+
formatNumber(-bounds.minX) +
|
|
201
|
+
' ' +
|
|
202
|
+
formatNumber(-bounds.minY) +
|
|
203
|
+
')"'
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Builds a bottom-left-anchored scale transform for sparse custom sheets
|
|
209
|
+
* whose declared page is larger than the recovered content envelope.
|
|
210
|
+
* @param {number} width
|
|
211
|
+
* @param {number} height
|
|
212
|
+
* @param {{ paperSize?: string, sourceWidth?: number, sourceHeight?: number } | undefined} sheet
|
|
213
|
+
* @param {number} margin
|
|
214
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
215
|
+
* @param {number} contentPadding
|
|
216
|
+
* @param {number} footerReserve
|
|
217
|
+
* @returns {string}
|
|
218
|
+
*/
|
|
219
|
+
static #buildSparseCustomSheetTransform(
|
|
220
|
+
width,
|
|
221
|
+
height,
|
|
222
|
+
sheet,
|
|
223
|
+
margin,
|
|
224
|
+
bounds,
|
|
225
|
+
contentPadding,
|
|
226
|
+
footerReserve
|
|
227
|
+
) {
|
|
228
|
+
if (
|
|
229
|
+
sheet?.paperSize ||
|
|
230
|
+
width !== Number(sheet?.sourceWidth || 0) ||
|
|
231
|
+
height !== Number(sheet?.sourceHeight || 0)
|
|
232
|
+
) {
|
|
233
|
+
return ''
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const virtualSourceSheet =
|
|
237
|
+
SchematicContentLayout.#resolveVirtualStandardSourceSheet(
|
|
238
|
+
width,
|
|
239
|
+
height,
|
|
240
|
+
margin,
|
|
241
|
+
bounds
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if (!virtualSourceSheet) {
|
|
245
|
+
return ''
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const virtualInnerWidth = virtualSourceSheet.width - margin * 2
|
|
249
|
+
const scale = (width - margin * 2) / virtualInnerWidth
|
|
250
|
+
|
|
251
|
+
if (!Number.isFinite(scale) || scale <= 1) {
|
|
252
|
+
return ''
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const pivotX = margin
|
|
256
|
+
const pivotY = height - margin
|
|
257
|
+
const projectedMinX = pivotX + (bounds.minX - pivotX) * scale
|
|
258
|
+
const projectedMaxX = pivotX + (bounds.maxX - pivotX) * scale
|
|
259
|
+
const projectedMinY = pivotY + (bounds.minY - pivotY) * scale
|
|
260
|
+
const projectedMaxY = pivotY + (bounds.maxY - pivotY) * scale
|
|
261
|
+
const topLimit = margin + contentPadding
|
|
262
|
+
const bottomLimit = height - margin - footerReserve
|
|
263
|
+
const rightLimit = width - margin
|
|
264
|
+
|
|
265
|
+
if (
|
|
266
|
+
projectedMinX < margin ||
|
|
267
|
+
projectedMaxX > rightLimit ||
|
|
268
|
+
projectedMinY < topLimit ||
|
|
269
|
+
projectedMaxY > bottomLimit
|
|
270
|
+
) {
|
|
271
|
+
return ''
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
' transform="translate(' +
|
|
276
|
+
formatNumber(pivotX) +
|
|
277
|
+
' ' +
|
|
278
|
+
formatNumber(pivotY) +
|
|
279
|
+
') scale(' +
|
|
280
|
+
formatNumber(scale) +
|
|
281
|
+
') translate(' +
|
|
282
|
+
formatNumber(-pivotX) +
|
|
283
|
+
' ' +
|
|
284
|
+
formatNumber(-pivotY) +
|
|
285
|
+
')"'
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Resolves the maximum sheet-wide scale implied by source and normalized
|
|
291
|
+
* page sizes.
|
|
292
|
+
* @param {number} width
|
|
293
|
+
* @param {number} height
|
|
294
|
+
* @param {{ sourceWidth?: number, sourceHeight?: number }} sheet
|
|
295
|
+
* @param {number} margin
|
|
296
|
+
* @returns {number}
|
|
297
|
+
*/
|
|
298
|
+
static #buildNormalizedSheetScaleLimit(width, height, sheet, margin) {
|
|
299
|
+
const sourceWidth = Number(sheet?.sourceWidth || 0)
|
|
300
|
+
const sourceHeight = Number(sheet?.sourceHeight || 0)
|
|
301
|
+
|
|
302
|
+
if (
|
|
303
|
+
sourceWidth <= margin * 2 ||
|
|
304
|
+
sourceHeight <= margin * 2 ||
|
|
305
|
+
(width <= sourceWidth && height <= sourceHeight)
|
|
306
|
+
) {
|
|
307
|
+
return 0
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return Math.min(
|
|
311
|
+
(width - margin * 2) / (sourceWidth - margin * 2),
|
|
312
|
+
(height - margin * 2) / (sourceHeight - margin * 2)
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Resolves a looser standard-page proxy for sparse custom sheets so the
|
|
318
|
+
* viewer can scale their content into the authored page without shrinking
|
|
319
|
+
* the sheet itself.
|
|
320
|
+
* @param {number} width
|
|
321
|
+
* @param {number} height
|
|
322
|
+
* @param {number} margin
|
|
323
|
+
* @param {{ minY: number, maxX: number }} bounds
|
|
324
|
+
* @returns {{ label: string, width: number, height: number } | null}
|
|
325
|
+
*/
|
|
326
|
+
static #resolveVirtualStandardSourceSheet(width, height, margin, bounds) {
|
|
327
|
+
const requiredWidth = Math.max(Number(bounds.maxX || 0) + margin * 2, 0)
|
|
328
|
+
const requiredHeight = Math.max(
|
|
329
|
+
height - Number(bounds.minY || 0) + margin * 2,
|
|
330
|
+
0
|
|
331
|
+
)
|
|
332
|
+
const landscape = requiredWidth >= requiredHeight
|
|
333
|
+
const candidates = ISO_A_PORTRAIT_SHEETS.map((sheet) => ({
|
|
334
|
+
label: sheet.label,
|
|
335
|
+
width: landscape ? sheet.height : sheet.width,
|
|
336
|
+
height: landscape ? sheet.width : sheet.height
|
|
337
|
+
}))
|
|
338
|
+
const matchingSheet =
|
|
339
|
+
candidates.find(
|
|
340
|
+
(sheet) =>
|
|
341
|
+
sheet.width >= requiredWidth &&
|
|
342
|
+
sheet.height >= requiredHeight
|
|
343
|
+
) || null
|
|
344
|
+
|
|
345
|
+
if (!matchingSheet) {
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const widthSlackRatio =
|
|
350
|
+
(matchingSheet.width - requiredWidth) / requiredWidth
|
|
351
|
+
const heightSlackRatio =
|
|
352
|
+
(matchingSheet.height - requiredHeight) / requiredHeight
|
|
353
|
+
|
|
354
|
+
return widthSlackRatio <= RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO &&
|
|
355
|
+
heightSlackRatio <= RELAXED_STANDARD_PAGE_MAX_SLACK_RATIO &&
|
|
356
|
+
matchingSheet.width < width
|
|
357
|
+
? matchingSheet
|
|
358
|
+
: null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolves one conservative padding band so scaled content leaves room for
|
|
363
|
+
* visible labels and stroke caps inside the sheet frame.
|
|
364
|
+
* @param {{ fonts?: Record<string, { size?: number }> } | undefined} sheet
|
|
365
|
+
* @param {number} margin
|
|
366
|
+
* @returns {number}
|
|
367
|
+
*/
|
|
368
|
+
static #resolveContentPadding(sheet, margin) {
|
|
369
|
+
const fontSizes = Object.values(sheet?.fonts || {}).map(
|
|
370
|
+
(font) =>
|
|
371
|
+
SchematicTypography.resolveViewerFontSize(
|
|
372
|
+
Number(font?.size || 0)
|
|
373
|
+
) || 0
|
|
374
|
+
)
|
|
375
|
+
const maxViewerFontSize = Math.max(...fontSizes, 0)
|
|
376
|
+
|
|
377
|
+
return Math.max(maxViewerFontSize * 1.5, margin)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Reserves a small bottom band when the page footer/title block is shown
|
|
382
|
+
* so vertically scaled content sits in the main drawing area instead of
|
|
383
|
+
* visually sagging toward the footer.
|
|
384
|
+
* @param {{ titleBlockOn?: boolean } | undefined} sheet
|
|
385
|
+
* @param {number} margin
|
|
386
|
+
* @returns {number}
|
|
387
|
+
*/
|
|
388
|
+
static #resolveFooterReserve(sheet, margin) {
|
|
389
|
+
if (!sheet?.titleBlockOn) {
|
|
390
|
+
return 0
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return margin
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Resolves the dominant rendered box that should guide normalized-sheet
|
|
398
|
+
* top anchoring when a large authored frame sits below smaller outliers.
|
|
399
|
+
* @param {{ rectangles?: { x: number, y: number, width: number, height: number }[], regions?: { x: number, y: number, width: number, height: number }[] }} schematic
|
|
400
|
+
* @param {number} sheetHeight
|
|
401
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
|
|
402
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
|
|
403
|
+
*/
|
|
404
|
+
static #resolveNormalizedAnchorBounds(schematic, sheetHeight, bounds) {
|
|
405
|
+
const totalWidth = bounds.maxX - bounds.minX
|
|
406
|
+
const totalHeight = bounds.maxY - bounds.minY
|
|
407
|
+
|
|
408
|
+
if (totalWidth <= 0 || totalHeight <= 0) {
|
|
409
|
+
return bounds
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const minimumWidth = totalWidth * 0.45
|
|
413
|
+
const minimumHeight = totalHeight * 0.2
|
|
414
|
+
const minimumArea = totalWidth * totalHeight * 0.12
|
|
415
|
+
const candidates = [
|
|
416
|
+
...SchematicContentLayout.#collectRenderedBoxBounds(
|
|
417
|
+
schematic?.rectangles,
|
|
418
|
+
sheetHeight
|
|
419
|
+
),
|
|
420
|
+
...SchematicContentLayout.#collectRenderedBoxBounds(
|
|
421
|
+
schematic?.regions,
|
|
422
|
+
sheetHeight
|
|
423
|
+
)
|
|
424
|
+
].filter((candidate) => {
|
|
425
|
+
const width = candidate.maxX - candidate.minX
|
|
426
|
+
const height = candidate.maxY - candidate.minY
|
|
427
|
+
const area = width * height
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
candidate.minY > bounds.minY &&
|
|
431
|
+
width >= minimumWidth &&
|
|
432
|
+
height >= minimumHeight &&
|
|
433
|
+
area >= minimumArea
|
|
434
|
+
)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
if (!candidates.length) {
|
|
438
|
+
return bounds
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
candidates.sort(
|
|
442
|
+
(left, right) =>
|
|
443
|
+
left.minY - right.minY ||
|
|
444
|
+
(right.maxX - right.minX) * (right.maxY - right.minY) -
|
|
445
|
+
(left.maxX - left.minX) * (left.maxY - left.minY)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return candidates[0]
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Collects one approximate visible content envelope in rendered SVG
|
|
453
|
+
* coordinates.
|
|
454
|
+
* @param {{ lines?: { x1: number, y1: number, x2: number, y2: number }[], polygons?: { points: { x: number, y: number }[] }[], rectangles?: { x: number, y: number, width: number, height: number }[], regions?: { x: number, y: number, width: number, height: number }[], ellipses?: { x: number, y: number, radiusX: number, radiusY: number }[], arcs?: { x: number, y: number, radius: number }[], texts?: { x: number, y: number }[], components?: { x: number, y: number }[], pins?: { x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[], ports?: { x: number, y: number, width: number, height: number, direction?: 'left' | 'right' | 'up' | 'down' }[], crosses?: { x: number, y: number, size?: number }[] }} schematic
|
|
455
|
+
* @param {number} sheetHeight
|
|
456
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
457
|
+
*/
|
|
458
|
+
static #collectRenderedContentBounds(schematic, sheetHeight) {
|
|
459
|
+
const coordinates = []
|
|
460
|
+
|
|
461
|
+
for (const line of schematic?.lines || []) {
|
|
462
|
+
coordinates.push(
|
|
463
|
+
[line.x1, projectSchematicY(sheetHeight, line.y1)],
|
|
464
|
+
[line.x2, projectSchematicY(sheetHeight, line.y2)]
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (const polygon of schematic?.polygons || []) {
|
|
469
|
+
for (const point of polygon.points || []) {
|
|
470
|
+
coordinates.push([
|
|
471
|
+
point.x,
|
|
472
|
+
projectSchematicY(sheetHeight, point.y)
|
|
473
|
+
])
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
for (const rectangle of schematic?.rectangles || []) {
|
|
478
|
+
coordinates.push(
|
|
479
|
+
[
|
|
480
|
+
rectangle.x,
|
|
481
|
+
projectSchematicY(
|
|
482
|
+
sheetHeight,
|
|
483
|
+
rectangle.y + rectangle.height
|
|
484
|
+
)
|
|
485
|
+
],
|
|
486
|
+
[
|
|
487
|
+
rectangle.x + rectangle.width,
|
|
488
|
+
projectSchematicY(sheetHeight, rectangle.y)
|
|
489
|
+
]
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const region of schematic?.regions || []) {
|
|
494
|
+
coordinates.push(
|
|
495
|
+
[
|
|
496
|
+
region.x,
|
|
497
|
+
projectSchematicY(sheetHeight, region.y + region.height)
|
|
498
|
+
],
|
|
499
|
+
[
|
|
500
|
+
region.x + region.width,
|
|
501
|
+
projectSchematicY(sheetHeight, region.y)
|
|
502
|
+
]
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
for (const ellipse of schematic?.ellipses || []) {
|
|
507
|
+
coordinates.push(
|
|
508
|
+
[
|
|
509
|
+
ellipse.x - Math.max(Number(ellipse.radiusX || 0), 0),
|
|
510
|
+
projectSchematicY(
|
|
511
|
+
sheetHeight,
|
|
512
|
+
ellipse.y + Math.max(Number(ellipse.radiusY || 0), 0)
|
|
513
|
+
)
|
|
514
|
+
],
|
|
515
|
+
[
|
|
516
|
+
ellipse.x + Math.max(Number(ellipse.radiusX || 0), 0),
|
|
517
|
+
projectSchematicY(
|
|
518
|
+
sheetHeight,
|
|
519
|
+
ellipse.y - Math.max(Number(ellipse.radiusY || 0), 0)
|
|
520
|
+
)
|
|
521
|
+
]
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
for (const arc of schematic?.arcs || []) {
|
|
526
|
+
const radius = Math.max(Number(arc.radius || 0), 0)
|
|
527
|
+
coordinates.push(
|
|
528
|
+
[
|
|
529
|
+
arc.x - radius,
|
|
530
|
+
projectSchematicY(sheetHeight, arc.y + radius)
|
|
531
|
+
],
|
|
532
|
+
[arc.x + radius, projectSchematicY(sheetHeight, arc.y - radius)]
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
for (const text of schematic?.texts || []) {
|
|
537
|
+
coordinates.push([text.x, projectSchematicY(sheetHeight, text.y)])
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
for (const component of schematic?.components || []) {
|
|
541
|
+
coordinates.push([
|
|
542
|
+
component.x,
|
|
543
|
+
projectSchematicY(sheetHeight, component.y)
|
|
544
|
+
])
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (const pin of schematic?.pins || []) {
|
|
548
|
+
const geometry = SchematicContentLayout.#projectPinGeometry(pin)
|
|
549
|
+
if (!geometry) {
|
|
550
|
+
continue
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
coordinates.push(
|
|
554
|
+
[
|
|
555
|
+
geometry.bodyX,
|
|
556
|
+
projectSchematicY(sheetHeight, geometry.bodyY)
|
|
557
|
+
],
|
|
558
|
+
[
|
|
559
|
+
geometry.outerX,
|
|
560
|
+
projectSchematicY(sheetHeight, geometry.outerY)
|
|
561
|
+
]
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for (const port of schematic?.ports || []) {
|
|
566
|
+
if (port.direction === 'up' || port.direction === 'down') {
|
|
567
|
+
const halfWidth = Number(port.height || 0) / 2
|
|
568
|
+
coordinates.push(
|
|
569
|
+
[
|
|
570
|
+
port.x - halfWidth,
|
|
571
|
+
projectSchematicY(sheetHeight, port.y + port.width)
|
|
572
|
+
],
|
|
573
|
+
[port.x + halfWidth, projectSchematicY(sheetHeight, port.y)]
|
|
574
|
+
)
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
coordinates.push(
|
|
579
|
+
[port.x, projectSchematicY(sheetHeight, port.y + port.height)],
|
|
580
|
+
[port.x + port.width, projectSchematicY(sheetHeight, port.y)]
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
for (const cross of schematic?.crosses || []) {
|
|
585
|
+
const half = Math.max(Number(cross.size || 6), 4) / 2
|
|
586
|
+
coordinates.push(
|
|
587
|
+
[
|
|
588
|
+
cross.x - half,
|
|
589
|
+
projectSchematicY(sheetHeight, cross.y) - half
|
|
590
|
+
],
|
|
591
|
+
[cross.x + half, projectSchematicY(sheetHeight, cross.y) + half]
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!coordinates.length) {
|
|
596
|
+
return null
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
minX: Math.min(...coordinates.map(([x]) => x)),
|
|
601
|
+
minY: Math.min(...coordinates.map(([, y]) => y)),
|
|
602
|
+
maxX: Math.max(...coordinates.map(([x]) => x)),
|
|
603
|
+
maxY: Math.max(...coordinates.map(([, y]) => y))
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Projects authored rectangles or regions into rendered SVG bounds.
|
|
609
|
+
* @param {{ x: number, y: number, width: number, height: number }[] | undefined} boxes
|
|
610
|
+
* @param {number} sheetHeight
|
|
611
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }[]}
|
|
612
|
+
*/
|
|
613
|
+
static #collectRenderedBoxBounds(boxes, sheetHeight) {
|
|
614
|
+
return (boxes || []).map((box) => ({
|
|
615
|
+
minX: box.x,
|
|
616
|
+
minY: projectSchematicY(sheetHeight, box.y + box.height),
|
|
617
|
+
maxX: box.x + box.width,
|
|
618
|
+
maxY: projectSchematicY(sheetHeight, box.y)
|
|
619
|
+
}))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Computes the inner endpoint for one schematic pin stub.
|
|
624
|
+
* @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
|
|
625
|
+
* @returns {{ bodyX: number, bodyY: number, outerX: number, outerY: number } | null}
|
|
626
|
+
*/
|
|
627
|
+
static #projectPinGeometry(pin) {
|
|
628
|
+
switch (pin.orientation) {
|
|
629
|
+
case 'left':
|
|
630
|
+
return {
|
|
631
|
+
bodyX: pin.x,
|
|
632
|
+
bodyY: pin.y,
|
|
633
|
+
outerX: pin.x - pin.length,
|
|
634
|
+
outerY: pin.y
|
|
635
|
+
}
|
|
636
|
+
case 'right':
|
|
637
|
+
return {
|
|
638
|
+
bodyX: pin.x,
|
|
639
|
+
bodyY: pin.y,
|
|
640
|
+
outerX: pin.x + pin.length,
|
|
641
|
+
outerY: pin.y
|
|
642
|
+
}
|
|
643
|
+
case 'top':
|
|
644
|
+
return {
|
|
645
|
+
bodyX: pin.x,
|
|
646
|
+
bodyY: pin.y,
|
|
647
|
+
outerX: pin.x,
|
|
648
|
+
outerY: pin.y + pin.length
|
|
649
|
+
}
|
|
650
|
+
case 'bottom':
|
|
651
|
+
return {
|
|
652
|
+
bodyX: pin.x,
|
|
653
|
+
bodyY: pin.y,
|
|
654
|
+
outerX: pin.x,
|
|
655
|
+
outerY: pin.y - pin.length
|
|
656
|
+
}
|
|
657
|
+
default:
|
|
658
|
+
return null
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|