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,485 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
|
+
import { SchematicTextParser } from './SchematicTextParser.mjs'
|
|
7
|
+
|
|
8
|
+
const { getField, parseNumericField } = ParserUtils
|
|
9
|
+
const ISO_A_PORTRAIT_SHEETS = [
|
|
10
|
+
{ label: 'A5', width: 583, height: 827 },
|
|
11
|
+
{ label: 'A4', width: 827, height: 1169 },
|
|
12
|
+
{ label: 'A3', width: 1169, height: 1654 },
|
|
13
|
+
{ label: 'A2', width: 1654, height: 2339 },
|
|
14
|
+
{ label: 'A1', width: 2339, height: 3307 },
|
|
15
|
+
{ label: 'A0', width: 3307, height: 4681 }
|
|
16
|
+
]
|
|
17
|
+
const STANDARD_PAGE_MAX_SLACK_RATIO = 0.12
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shared layout helpers for recovered schematic and PCB document geometry.
|
|
21
|
+
*/
|
|
22
|
+
export class AltiumLayoutParser {
|
|
23
|
+
/**
|
|
24
|
+
* Builds an outline from the serialized board polygon fields.
|
|
25
|
+
* @param {Record<string, string | string[]>} fields
|
|
26
|
+
* @returns {{ widthMil: number, heightMil: number, minX: number, minY: number, segments: Array<Record<string, number | string>> }}
|
|
27
|
+
*/
|
|
28
|
+
static parseBoardOutline(fields) {
|
|
29
|
+
const vertices = []
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < 1024; index += 1) {
|
|
32
|
+
const kind = parseNumericField(fields, 'KIND' + index)
|
|
33
|
+
const x = parseNumericField(fields, 'VX' + index)
|
|
34
|
+
const y = parseNumericField(fields, 'VY' + index)
|
|
35
|
+
|
|
36
|
+
if (kind === null || x === null || y === null) {
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
vertices.push({
|
|
41
|
+
kind,
|
|
42
|
+
x,
|
|
43
|
+
y,
|
|
44
|
+
cx: parseNumericField(fields, 'CX' + index),
|
|
45
|
+
cy: parseNumericField(fields, 'CY' + index),
|
|
46
|
+
radius: parseNumericField(fields, 'R' + index),
|
|
47
|
+
startAngle: parseNumericField(fields, 'SA' + index),
|
|
48
|
+
endAngle: parseNumericField(fields, 'EA' + index)
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!vertices.length) {
|
|
53
|
+
return {
|
|
54
|
+
widthMil: 0,
|
|
55
|
+
heightMil: 0,
|
|
56
|
+
minX: 0,
|
|
57
|
+
minY: 0,
|
|
58
|
+
segments: []
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const segments = []
|
|
63
|
+
const xs = vertices.map((vertex) => vertex.x)
|
|
64
|
+
const ys = vertices.map((vertex) => vertex.y)
|
|
65
|
+
|
|
66
|
+
for (let index = 0; index < vertices.length; index += 1) {
|
|
67
|
+
const current = vertices[index]
|
|
68
|
+
const next = vertices[(index + 1) % vertices.length]
|
|
69
|
+
|
|
70
|
+
if (current.kind === 1 && current.radius) {
|
|
71
|
+
segments.push({
|
|
72
|
+
type: 'arc',
|
|
73
|
+
x1: current.x,
|
|
74
|
+
y1: current.y,
|
|
75
|
+
x2: next.x,
|
|
76
|
+
y2: next.y,
|
|
77
|
+
cx: current.cx || current.x,
|
|
78
|
+
cy: current.cy || current.y,
|
|
79
|
+
radius: current.radius,
|
|
80
|
+
startAngle: current.startAngle || 0,
|
|
81
|
+
endAngle: current.endAngle || 0
|
|
82
|
+
})
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
segments.push({
|
|
87
|
+
type: 'line',
|
|
88
|
+
x1: current.x,
|
|
89
|
+
y1: current.y,
|
|
90
|
+
x2: next.x,
|
|
91
|
+
y2: next.y
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
widthMil: Math.max(...xs) - Math.min(...xs),
|
|
97
|
+
heightMil: Math.max(...ys) - Math.min(...ys),
|
|
98
|
+
minX: Math.min(...xs),
|
|
99
|
+
minY: Math.min(...ys),
|
|
100
|
+
segments
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extracts the declared layer stack.
|
|
106
|
+
* @param {Record<string, string | string[]>} fields
|
|
107
|
+
* @returns {{ index: number, name: string, layerId: number | null }[]}
|
|
108
|
+
*/
|
|
109
|
+
static parseLayerStack(fields) {
|
|
110
|
+
const layers = []
|
|
111
|
+
|
|
112
|
+
for (const key of Object.keys(fields)) {
|
|
113
|
+
const match = /^V9_STACK_LAYER(\d+)_NAME$/.exec(key)
|
|
114
|
+
if (!match) continue
|
|
115
|
+
|
|
116
|
+
const index = Number.parseInt(match[1], 10)
|
|
117
|
+
layers.push({
|
|
118
|
+
index,
|
|
119
|
+
name: getField(fields, key),
|
|
120
|
+
layerId: parseNumericField(
|
|
121
|
+
fields,
|
|
122
|
+
'V9_STACK_LAYER' + index + '_LAYERID'
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return layers.sort((left, right) => left.index - right.index)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Extracts legacy primitive-layer names keyed by the numeric layer IDs used
|
|
132
|
+
* by decoded binary track and fill streams.
|
|
133
|
+
* @param {Record<string, string | string[]>[]} fieldSets
|
|
134
|
+
* @returns {{ layerId: number, name: string }[]}
|
|
135
|
+
*/
|
|
136
|
+
static parsePrimitiveLayerNames(fieldSets) {
|
|
137
|
+
const layers = new Map()
|
|
138
|
+
|
|
139
|
+
for (const fields of fieldSets) {
|
|
140
|
+
for (const key of Object.keys(fields)) {
|
|
141
|
+
const match = /^LAYER(\d+)NAME$/.exec(key)
|
|
142
|
+
|
|
143
|
+
if (!match) {
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const layerId = Number.parseInt(match[1], 10)
|
|
148
|
+
const name = getField(fields, key)
|
|
149
|
+
|
|
150
|
+
if (!Number.isInteger(layerId) || !name) {
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!layers.has(layerId)) {
|
|
155
|
+
layers.set(layerId, {
|
|
156
|
+
layerId,
|
|
157
|
+
name
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [...layers.values()].sort(
|
|
164
|
+
(left, right) => left.layerId - right.layerId
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolves one schematic page size from recovered geometry when the stored
|
|
170
|
+
* custom dimensions leave excessive blank space around visible content.
|
|
171
|
+
* @param {{ width: number, height: number, marginWidth: number, paperSize?: string }} sheet
|
|
172
|
+
* @param {{ fields: Record<string, string | string[]> }[]} textRecords
|
|
173
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
|
|
174
|
+
* @param {{ x: number, y: number }[]} texts
|
|
175
|
+
* @param {{ x: number, y: number }[]} components
|
|
176
|
+
* @param {{ x: number, y: number }[]} pins
|
|
177
|
+
* @param {{ x: number, y: number, width: number, height: number }[]} rectangles
|
|
178
|
+
* @param {{ x: number, y: number, width: number, height: number }[]} regions
|
|
179
|
+
* @param {{ x: number, y: number, width: number, height: number }[]} ports
|
|
180
|
+
* @param {{ x: number, y: number }[]} crosses
|
|
181
|
+
* @returns {{ width: number, height: number, marginWidth: number, paperSize?: string }}
|
|
182
|
+
*/
|
|
183
|
+
static resolveSchematicSheetSize(
|
|
184
|
+
sheet,
|
|
185
|
+
textRecords,
|
|
186
|
+
lines,
|
|
187
|
+
texts,
|
|
188
|
+
components,
|
|
189
|
+
pins,
|
|
190
|
+
rectangles,
|
|
191
|
+
regions,
|
|
192
|
+
ports,
|
|
193
|
+
crosses
|
|
194
|
+
) {
|
|
195
|
+
const bounds = AltiumLayoutParser.#collectSchematicDrawableBounds(
|
|
196
|
+
lines,
|
|
197
|
+
texts,
|
|
198
|
+
components,
|
|
199
|
+
pins,
|
|
200
|
+
rectangles,
|
|
201
|
+
regions,
|
|
202
|
+
ports,
|
|
203
|
+
crosses
|
|
204
|
+
)
|
|
205
|
+
if (!bounds) {
|
|
206
|
+
return sheet
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const margin = Math.max(Number(sheet?.marginWidth || 20), 20)
|
|
210
|
+
const footerBounds = AltiumLayoutParser.#collectSchematicFooterBounds(
|
|
211
|
+
textRecords,
|
|
212
|
+
Number(sheet?.width || 0)
|
|
213
|
+
)
|
|
214
|
+
const requiredWidth =
|
|
215
|
+
Math.max(bounds.maxX, footerBounds?.maxX || 0) + margin * 2
|
|
216
|
+
const requiredHeight =
|
|
217
|
+
Math.max(bounds.maxY, footerBounds?.maxY || 0) + margin * 2
|
|
218
|
+
|
|
219
|
+
if (
|
|
220
|
+
AltiumLayoutParser.#shouldPreserveDeclaredCustomSheetSize(
|
|
221
|
+
sheet,
|
|
222
|
+
requiredWidth,
|
|
223
|
+
requiredHeight
|
|
224
|
+
)
|
|
225
|
+
) {
|
|
226
|
+
return sheet
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const standardSheet = AltiumLayoutParser.#resolveStandardSheetSize(
|
|
230
|
+
requiredWidth,
|
|
231
|
+
requiredHeight
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if (standardSheet) {
|
|
235
|
+
return {
|
|
236
|
+
...sheet,
|
|
237
|
+
width: standardSheet.width,
|
|
238
|
+
height: standardSheet.height,
|
|
239
|
+
paperSize: standardSheet.label
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const resolvedWidth = AltiumLayoutParser.#pickResolvedSheetAxis(
|
|
244
|
+
sheet.width,
|
|
245
|
+
requiredWidth
|
|
246
|
+
)
|
|
247
|
+
const resolvedHeight = AltiumLayoutParser.#pickResolvedSheetAxis(
|
|
248
|
+
sheet.height,
|
|
249
|
+
requiredHeight
|
|
250
|
+
)
|
|
251
|
+
const resolvedStandardSheet =
|
|
252
|
+
AltiumLayoutParser.#resolveStandardSheetSize(
|
|
253
|
+
resolvedWidth,
|
|
254
|
+
resolvedHeight
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if (resolvedStandardSheet) {
|
|
258
|
+
return {
|
|
259
|
+
...sheet,
|
|
260
|
+
width: resolvedStandardSheet.width,
|
|
261
|
+
height: resolvedStandardSheet.height,
|
|
262
|
+
paperSize: resolvedStandardSheet.label
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
...sheet,
|
|
268
|
+
width: resolvedWidth,
|
|
269
|
+
height: resolvedHeight,
|
|
270
|
+
paperSize: sheet?.paperSize
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Returns true when the parser should trust the authored custom sheet
|
|
276
|
+
* dimensions instead of shrinking the page to visible content bounds.
|
|
277
|
+
* @param {{ width?: number, height?: number, borderOn?: boolean, titleBlockOn?: boolean, sheetStyle?: number } | undefined} sheet
|
|
278
|
+
* @param {number} requiredWidth
|
|
279
|
+
* @param {number} requiredHeight
|
|
280
|
+
* @returns {boolean}
|
|
281
|
+
*/
|
|
282
|
+
static #shouldPreserveDeclaredCustomSheetSize(
|
|
283
|
+
sheet,
|
|
284
|
+
requiredWidth,
|
|
285
|
+
requiredHeight
|
|
286
|
+
) {
|
|
287
|
+
const declaredStandardSheet =
|
|
288
|
+
AltiumLayoutParser.#resolveStandardSheetSize(
|
|
289
|
+
Number(sheet?.width || 0),
|
|
290
|
+
Number(sheet?.height || 0)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if (
|
|
294
|
+
Number(sheet?.sheetStyle || 0) !== 1 &&
|
|
295
|
+
Boolean(sheet?.borderOn || sheet?.titleBlockOn) &&
|
|
296
|
+
!declaredStandardSheet
|
|
297
|
+
) {
|
|
298
|
+
const declaredWidth = Math.max(Number(sheet?.width || 0), 0)
|
|
299
|
+
const declaredHeight = Math.max(Number(sheet?.height || 0), 0)
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
requiredWidth <= declaredWidth &&
|
|
303
|
+
requiredHeight <= declaredHeight
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Collects the visible coordinate envelope from recovered schematic
|
|
312
|
+
* primitives.
|
|
313
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
|
|
314
|
+
* @param {{ x: number, y: number }[]} texts
|
|
315
|
+
* @param {{ x: number, y: number }[]} components
|
|
316
|
+
* @param {{ x: number, y: number }[]} pins
|
|
317
|
+
* @param {{ x: number, y: number, width: number, height: number }[]} rectangles
|
|
318
|
+
* @param {{ x: number, y: number, width: number, height: number }[]} regions
|
|
319
|
+
* @param {{ x: number, y: number, width: number, height: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
|
|
320
|
+
* @param {{ x: number, y: number }[]} crosses
|
|
321
|
+
* @returns {{ maxX: number, maxY: number } | null}
|
|
322
|
+
*/
|
|
323
|
+
static #collectSchematicDrawableBounds(
|
|
324
|
+
lines,
|
|
325
|
+
texts,
|
|
326
|
+
components,
|
|
327
|
+
pins,
|
|
328
|
+
rectangles,
|
|
329
|
+
regions,
|
|
330
|
+
ports,
|
|
331
|
+
crosses
|
|
332
|
+
) {
|
|
333
|
+
const coordinates = []
|
|
334
|
+
|
|
335
|
+
for (const line of lines) {
|
|
336
|
+
coordinates.push([line.x1, line.y1], [line.x2, line.y2])
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const text of texts) {
|
|
340
|
+
coordinates.push([text.x, text.y])
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const component of components) {
|
|
344
|
+
coordinates.push([component.x, component.y])
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
for (const pin of pins) {
|
|
348
|
+
coordinates.push([pin.x, pin.y])
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const rectangle of rectangles) {
|
|
352
|
+
coordinates.push(
|
|
353
|
+
[rectangle.x, rectangle.y],
|
|
354
|
+
[rectangle.x + rectangle.width, rectangle.y + rectangle.height]
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const region of regions) {
|
|
359
|
+
coordinates.push(
|
|
360
|
+
[region.x, region.y],
|
|
361
|
+
[region.x + region.width, region.y + region.height]
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (const port of ports) {
|
|
366
|
+
if (port.direction === 'up' || port.direction === 'down') {
|
|
367
|
+
const halfWidth = Number(port.height || 0) / 2
|
|
368
|
+
|
|
369
|
+
coordinates.push(
|
|
370
|
+
[port.x - halfWidth, port.y],
|
|
371
|
+
[port.x + halfWidth, port.y + port.width]
|
|
372
|
+
)
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
coordinates.push(
|
|
377
|
+
[port.x, port.y],
|
|
378
|
+
[port.x + port.width, port.y + port.height]
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const cross of crosses) {
|
|
383
|
+
coordinates.push([cross.x, cross.y])
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!coordinates.length) {
|
|
387
|
+
return null
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
maxX: Math.max(...coordinates.map(([x]) => x)),
|
|
392
|
+
maxY: Math.max(...coordinates.map(([, y]) => y))
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Collects the visible title-block footer extent recovered from footer
|
|
398
|
+
* value placeholders.
|
|
399
|
+
* @param {{ fields: Record<string, string | string[]> }[]} textRecords
|
|
400
|
+
* @param {number} sheetWidth
|
|
401
|
+
* @returns {{ maxX: number, maxY: number } | null}
|
|
402
|
+
*/
|
|
403
|
+
static #collectSchematicFooterBounds(textRecords, sheetWidth) {
|
|
404
|
+
const footerCoordinates = textRecords
|
|
405
|
+
.filter((record) =>
|
|
406
|
+
SchematicTextParser.isTitleBlockFooterRecord(
|
|
407
|
+
record.fields,
|
|
408
|
+
sheetWidth
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
.map((record) => ({
|
|
412
|
+
x: parseNumericField(record.fields, 'Location.X') || 0,
|
|
413
|
+
y: parseNumericField(record.fields, 'Location.Y') || 0
|
|
414
|
+
}))
|
|
415
|
+
|
|
416
|
+
if (!footerCoordinates.length) {
|
|
417
|
+
return null
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
maxX: Math.max(
|
|
422
|
+
...footerCoordinates.map((coordinate) => coordinate.x)
|
|
423
|
+
),
|
|
424
|
+
maxY: Math.max(
|
|
425
|
+
...footerCoordinates.map((coordinate) => coordinate.y)
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Resolves the smallest matching ISO A sheet when the recovered geometry
|
|
432
|
+
* closely matches a standard page size.
|
|
433
|
+
* @param {number} requiredWidth
|
|
434
|
+
* @param {number} requiredHeight
|
|
435
|
+
* @returns {{ label: string, width: number, height: number } | null}
|
|
436
|
+
*/
|
|
437
|
+
static #resolveStandardSheetSize(requiredWidth, requiredHeight) {
|
|
438
|
+
const landscape = requiredWidth >= requiredHeight
|
|
439
|
+
const candidates = ISO_A_PORTRAIT_SHEETS.map((sheet) => ({
|
|
440
|
+
label: sheet.label,
|
|
441
|
+
width: landscape ? sheet.height : sheet.width,
|
|
442
|
+
height: landscape ? sheet.width : sheet.height
|
|
443
|
+
}))
|
|
444
|
+
const matchingSheet =
|
|
445
|
+
candidates.find(
|
|
446
|
+
(sheet) =>
|
|
447
|
+
sheet.width >= requiredWidth &&
|
|
448
|
+
sheet.height >= requiredHeight
|
|
449
|
+
) || null
|
|
450
|
+
|
|
451
|
+
if (!matchingSheet) {
|
|
452
|
+
return null
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const widthSlackRatio =
|
|
456
|
+
(matchingSheet.width - requiredWidth) / requiredWidth
|
|
457
|
+
const heightSlackRatio =
|
|
458
|
+
(matchingSheet.height - requiredHeight) / requiredHeight
|
|
459
|
+
|
|
460
|
+
return widthSlackRatio <= STANDARD_PAGE_MAX_SLACK_RATIO &&
|
|
461
|
+
heightSlackRatio <= STANDARD_PAGE_MAX_SLACK_RATIO
|
|
462
|
+
? matchingSheet
|
|
463
|
+
: null
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Chooses a sheet axis length, preferring recovered bounds when the stored
|
|
468
|
+
* size is substantially larger than the visible geometry.
|
|
469
|
+
* @param {number} declaredAxis
|
|
470
|
+
* @param {number} inferredAxis
|
|
471
|
+
* @returns {number}
|
|
472
|
+
*/
|
|
473
|
+
static #pickResolvedSheetAxis(declaredAxis, inferredAxis) {
|
|
474
|
+
const normalizedDeclared = Math.max(Number(declaredAxis || 0), 100)
|
|
475
|
+
const normalizedInferred = Math.max(Number(inferredAxis || 0), 100)
|
|
476
|
+
|
|
477
|
+
if (normalizedDeclared < normalizedInferred) {
|
|
478
|
+
return normalizedInferred
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return normalizedDeclared > normalizedInferred * 1.15
|
|
482
|
+
? normalizedInferred
|
|
483
|
+
: normalizedDeclared
|
|
484
|
+
}
|
|
485
|
+
}
|